diff --git a/packages/app-desktop/runForSharing.sh b/packages/app-desktop/runForSharing.sh index 03283b852..51295a182 100755 --- a/packages/app-desktop/runForSharing.sh +++ b/packages/app-desktop/runForSharing.sh @@ -31,7 +31,7 @@ if [ "$RESET_ALL" == "1" ]; then echo "config sync.9.password 123456" >> "$CMD_FILE" if [ "$1" == "1" ]; then - curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug + curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://localhost:22300/api/debug echo 'mkbook "shared"' >> "$CMD_FILE" echo 'mkbook "other"' >> "$CMD_FILE" diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index 211d814bc..9c4aa7332 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -48,10 +48,8 @@ export interface Command { * Currently the supported context variables aren't documented, but you can * find the list below: * - * - [Global When - * Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts). - * - [Desktop app When - * Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts). + * - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts) + * - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts) * * Note: Commands are enabled by default unless you use this property. */ diff --git a/packages/lib/utils/credentialFiles.js b/packages/lib/utils/credentialFiles.js index c5a08b876..a69d0776e 100644 --- a/packages/lib/utils/credentialFiles.js +++ b/packages/lib/utils/credentialFiles.js @@ -38,11 +38,16 @@ function credentialFile(filename) { }); } exports.credentialFile = credentialFile; -function readCredentialFile(filename) { +function readCredentialFile(filename, defaultValue = '') { return __awaiter(this, void 0, void 0, function* () { - const filePath = yield credentialFile(filename); - const r = yield fs.readFile(filePath); - return r.toString(); + try { + const filePath = yield credentialFile(filename); + const r = yield fs.readFile(filePath); + return r.toString(); + } + catch (error) { + return defaultValue; + } }); } exports.readCredentialFile = readCredentialFile; diff --git a/packages/lib/utils/credentialFiles.ts b/packages/lib/utils/credentialFiles.ts index 95933afcb..5d601efc4 100644 --- a/packages/lib/utils/credentialFiles.ts +++ b/packages/lib/utils/credentialFiles.ts @@ -24,8 +24,12 @@ export async function credentialFile(filename: string) { return output; } -export async function readCredentialFile(filename: string) { - const filePath = await credentialFile(filename); - const r = await fs.readFile(filePath); - return r.toString(); +export async function readCredentialFile(filename: string, defaultValue: string = '') { + try { + const filePath = await credentialFile(filename); + const r = await fs.readFile(filePath); + return r.toString(); + } catch (error) { + return defaultValue; + } } diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 7205e5a5a..822b19ef7 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -6,4 +6,5 @@ db-*.sqlite *.pid logs/ tests/temp/ -temp/ \ No newline at end of file +temp/ +.env \ No newline at end of file diff --git a/packages/server/nodemon.json b/packages/server/nodemon.json index ff3c3f550..3053f2f04 100644 --- a/packages/server/nodemon.json +++ b/packages/server/nodemon.json @@ -1,4 +1,9 @@ { "verbose": true, - "watch": ["dist/", "../renderer", "../lib"] + "watch": [ + "dist/", + "../renderer", + "../lib", + "src/views" + ] } \ No newline at end of file diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 5a4900291..9a17ebfb5 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1389,6 +1389,15 @@ "integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==", "dev": true }, + "@types/nodemailer": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.1.tgz", + "integrity": "sha512-8081UY/0XTTDpuGqCnDc8IY+Q3DSg604wB3dBH0CaZlj4nZWHWuxtZ3NRZ9c9WUrz1Vfm6wioAUnqL3bsh49uQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -5958,6 +5967,19 @@ "minimist": "^1.2.5" } }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, + "moment-timezone": { + "version": "0.5.33", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz", + "integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==", + "requires": { + "moment": ">= 2.9.0" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6045,6 +6067,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-cron": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz", + "integrity": "sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==", + "requires": { + "moment-timezone": "^0.5.31" + } + }, "node-env-file": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/node-env-file/-/node-env-file-0.1.8.tgz", @@ -6148,6 +6178,11 @@ } } }, + "nodemailer": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.6.0.tgz", + "integrity": "sha512-ikSMDU1nZqpo2WUPE0wTTw/NGGImTkwpJKDIFPZT+YvvR9Sj+ze5wzu95JHkBMglQLoG2ITxU21WukCC/XsFkg==" + }, "nodemon": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index be0589485..109ccb1a8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,7 +3,7 @@ "version": "2.0.1", "private": true, "scripts": { - "start-dev": "nodemon --config nodemon.json dist/app.js --env dev", + "start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev", "start": "node dist/app.js", "generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite", "tsc": "tsc --project tsconfig.json", @@ -30,11 +30,13 @@ "mustache": "^3.1.0", "nanoid": "^2.1.1", "node-env-file": "^0.1.8", + "nodemailer": "^6.6.0", "nodemon": "^2.0.6", "pg": "^8.5.1", "pretty-bytes": "^5.6.0", "query-string": "^6.8.3", "sqlite3": "^4.1.0", + "node-cron": "^3.0.0", "yargs": "^14.0.0" }, "devDependencies": { @@ -46,6 +48,7 @@ "@types/koa": "^2.0.49", "@types/markdown-it": "^12.0.0", "@types/mustache": "^0.8.32", + "@types/nodemailer": "^6.4.1", "@types/yargs": "^13.0.2", "jest": "^26.6.3", "jsdom": "^16.4.0", diff --git a/packages/server/schema.sqlite b/packages/server/schema.sqlite index a4ff59e82..59ba6503f 100644 Binary files a/packages/server/schema.sqlite and b/packages/server/schema.sqlite differ diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 593ef2dc3..7fdd3cd8e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -67,11 +67,22 @@ function markPasswords(o: Record): Record { return output; } -async function main() { - if (argv.envFile) { - nodeEnvFile(argv.envFile); +async function getEnvFilePath(env: Env, argv: any): Promise { + if (argv.envFile) return argv.envFile; + + if (env === Env.Dev) { + const envFilePath = `${require('os').homedir()}/joplin-credentials/server.env`; + if (await fs.pathExists(envFilePath)) return envFilePath; } + return ''; +} + +async function main() { + const envFilePath = await getEnvFilePath(env, argv); + + if (envFilePath) nodeEnvFile(envFilePath); + if (!envVariables[env]) throw new Error(`Invalid env: ${env}`); initConfig({ @@ -91,6 +102,8 @@ async function main() { }); Logger.initializeGlobalLogger(globalLogger); + if (envFilePath) appLogger().info(`Env variables were loaded from: ${envFilePath}`); + const pidFile = argv.pidfile as string; if (pidFile) { @@ -129,7 +142,7 @@ async function main() { const appContext = app.context as AppContext; await setupAppContext(appContext, env, connectionCheck.connection, appLogger); - await initializeJoplinUtils(config(), appContext.models); + await initializeJoplinUtils(config(), appContext.models, appContext.services.mustache); appLogger().info('Migrating database...'); await migrateDb(appContext.db); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index fc72bfbb2..8ef12a17c 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,5 +1,5 @@ import { rtrimSlashes } from '@joplin/lib/path-utils'; -import { Config, DatabaseConfig, DatabaseConfigClient } from './utils/types'; +import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig } from './utils/types'; import * as pathUtils from 'path'; export interface EnvVariables { @@ -14,6 +14,15 @@ export interface EnvVariables { POSTGRES_HOST?: string; POSTGRES_PORT?: string; + MAILER_ENABLED?: string; + MAILER_HOST?: string; + MAILER_PORT?: string; + MAILER_SECURE?: string; + MAILER_AUTH_USER?: string; + MAILER_AUTH_PASSWORD?: string; + MAILER_NOREPLY_NAME?: string; + MAILER_NOREPLY_EMAIL?: string; + SQLITE_DATABASE?: string; } @@ -57,6 +66,19 @@ function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): Dat }; } +function mailerConfigFromEnv(env: EnvVariables): MailerConfig { + return { + enabled: env.MAILER_ENABLED !== '0', + host: env.MAILER_HOST || '', + port: Number(env.MAILER_PORT || 587), + secure: !!Number(env.MAILER_SECURE) || true, + authUser: env.MAILER_AUTH_USER || '', + authPassword: env.MAILER_AUTH_PASSWORD || '', + noReplyName: env.MAILER_NOREPLY_NAME || '', + noReplyEmail: env.MAILER_NOREPLY_EMAIL || '', + }; +} + function baseUrlFromEnv(env: any, appPort: number): string { if (env.APP_BASE_URL) { return rtrimSlashes(env.APP_BASE_URL); @@ -81,6 +103,7 @@ export function initConfig(env: EnvVariables, overrides: any = null) { tempDir: `${rootDir}/temp`, logDir: `${rootDir}/logs`, database: databaseConfigFromEnv(runningInDocker_, env), + mailer: mailerConfigFromEnv(env), port: appPort, baseUrl: baseUrlFromEnv(env, appPort), ...overrides, diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index d7f908e79..b2e29dcc1 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -218,6 +218,11 @@ export enum ItemType { User, } +export enum EmailSender { + NoReply = 1, + Support = 2, +} + export enum ChangeType { Create = 1, Update = 2, @@ -277,6 +282,8 @@ export interface User extends WithDates, WithUuid { is_admin?: number; max_item_size?: number; can_share?: number; + email_confirmed?: number; + must_set_password?: number; } export interface Session extends WithDates, WithUuid { @@ -370,6 +377,25 @@ export interface Change extends WithDates, WithUuid { user_id?: Uuid; } +export interface Email extends WithDates { + id?: number; + recipient_name?: string; + recipient_email?: string; + recipient_id?: Uuid; + sender_id?: EmailSender; + subject?: string; + body?: string; + sent_time?: number; + sent_success?: number; + error?: string; +} + +export interface Token extends WithDates { + id?: number; + value?: string; + user_id?: Uuid; +} + export const databaseSchema: DatabaseTables = { users: { id: { type: 'string' }, @@ -381,6 +407,8 @@ export const databaseSchema: DatabaseTables = { created_time: { type: 'string' }, max_item_size: { type: 'number' }, can_share: { type: 'number' }, + email_confirmed: { type: 'number' }, + must_set_password: { type: 'number' }, }, sessions: { id: { type: 'string' }, @@ -485,5 +513,26 @@ export const databaseSchema: DatabaseTables = { previous_item: { type: 'string' }, user_id: { type: 'string' }, }, + emails: { + id: { type: 'number' }, + recipient_name: { type: 'string' }, + recipient_email: { type: 'string' }, + recipient_id: { type: 'string' }, + sender_id: { type: 'number' }, + subject: { type: 'string' }, + body: { type: 'string' }, + sent_time: { type: 'string' }, + sent_success: { type: 'number' }, + error: { type: 'string' }, + updated_time: { type: 'string' }, + created_time: { type: 'string' }, + }, + tokens: { + id: { type: 'number' }, + value: { type: 'string' }, + user_id: { type: 'string' }, + 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 index 04874cc18..3732b98ab 100644 --- a/packages/server/src/middleware/notificationHandler.ts +++ b/packages/server/src/middleware/notificationHandler.ts @@ -19,7 +19,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) { ctx.owner.id, '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()) + _('The default admin password is insecure and has not been changed! [Change it now](%s)', ctx.models.user().profileUrl()) ); } else { await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password'); diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 74dd2ebfa..acb8f69a8 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -1,15 +1,15 @@ import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils'; import { AppContext, Env } from '../utils/types'; -import MustacheService, { isView, View } from '../services/MustacheService'; -import config from '../config'; +import { isView, View } from '../services/MustacheService'; +// import config from '../config'; -let mustache_: MustacheService = null; -function mustache(): MustacheService { - if (!mustache_) { - mustache_ = new MustacheService(config().viewDir, config().baseUrl); - } - return mustache_; -} +// let mustache_: MustacheService = null; +// function mustache(): MustacheService { +// if (!mustache_) { +// mustache_ = new MustacheService(config().viewDir, config().baseUrl); +// } +// return mustache_; +// } export default async function(ctx: AppContext) { ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`); @@ -21,7 +21,7 @@ export default async function(ctx: AppContext) { ctx.response = responseObject.response; } else if (isView(responseObject)) { ctx.response.status = 200; - ctx.response.body = await mustache().renderView(responseObject, { + ctx.response.body = await ctx.services.mustache.renderView(responseObject, { notifications: ctx.notifications || [], hasNotifications: !!ctx.notifications && !!ctx.notifications.length, owner: ctx.owner, @@ -55,7 +55,7 @@ export default async function(ctx: AppContext) { owner: ctx.owner, }, }; - ctx.response.body = await mustache().renderView(view); + ctx.response.body = await ctx.services.mustache.renderView(view); } else { // JSON ctx.response.set('Content-Type', 'application/json'); const r: any = { error: error.message }; diff --git a/packages/server/src/migrations/20210518172311_mailer.ts b/packages/server/src/migrations/20210518172311_mailer.ts new file mode 100644 index 000000000..82acf61a9 --- /dev/null +++ b/packages/server/src/migrations/20210518172311_mailer.ts @@ -0,0 +1,47 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) { + table.integer('email_confirmed').defaultTo(0).notNullable(); + table.integer('must_set_password').defaultTo(0).notNullable(); + }); + + await db.schema.createTable('emails', function(table: Knex.CreateTableBuilder) { + table.increments('id').unique().primary().notNullable(); + table.text('recipient_name', 'mediumtext').defaultTo('').notNullable(); + table.text('recipient_email', 'mediumtext').defaultTo('').notNullable(); + table.string('recipient_id', 32).defaultTo(0).notNullable(); + table.integer('sender_id').notNullable(); + table.string('subject', 128).notNullable(); + table.text('body').notNullable(); + table.bigInteger('sent_time').defaultTo(0).notNullable(); + table.integer('sent_success').defaultTo(0).notNullable(); + table.text('error').defaultTo('').notNullable(); + table.bigInteger('updated_time').notNullable(); + table.bigInteger('created_time').notNullable(); + }); + + await db.schema.createTable('tokens', function(table: Knex.CreateTableBuilder) { + table.increments('id').unique().primary().notNullable(); + table.string('value', 32).notNullable(); + table.string('user_id', 32).defaultTo('').notNullable(); + table.bigInteger('updated_time').notNullable(); + table.bigInteger('created_time').notNullable(); + }); + + await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) { + table.index(['sent_time']); + table.index(['sent_success']); + }); + + await db('users').update({ email_confirmed: 1 }); + + await db.schema.alterTable('tokens', function(table: Knex.CreateTableBuilder) { + table.index(['value', 'user_id']); + }); +} + +export async function down(_db: DbConnection): Promise { + +} diff --git a/packages/server/src/models/BaseModel.ts b/packages/server/src/models/BaseModel.ts index aa1671c80..c4cd587e3 100644 --- a/packages/server/src/models/BaseModel.ts +++ b/packages/server/src/models/BaseModel.ts @@ -272,10 +272,10 @@ export default abstract class BaseModel { return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first(); } - public async delete(id: string | string[], options: DeleteOptions = {}): Promise { + public async delete(id: string | string[] | number | number[], options: DeleteOptions = {}): Promise { if (!id) throw new Error('id cannot be empty'); - const ids = typeof id === 'string' ? [id] : id; + const ids = (typeof id === 'string' || typeof id === 'number') ? [id] : id; if (!ids.length) throw new Error('no id provided'); diff --git a/packages/server/src/models/EmailModel.ts b/packages/server/src/models/EmailModel.ts new file mode 100644 index 000000000..89d21d238 --- /dev/null +++ b/packages/server/src/models/EmailModel.ts @@ -0,0 +1,33 @@ +import { Uuid, Email, EmailSender } from '../db'; +import BaseModel from './BaseModel'; + +export interface EmailToSend { + sender_id: EmailSender; + recipient_email: string; + subject: string; + body: string; + + recipient_name?: string; + recipient_id?: Uuid; +} + +export default class EmailModel extends BaseModel { + + public get tableName(): string { + return 'emails'; + } + + protected hasUuid(): boolean { + return false; + } + + public async push(email: EmailToSend) { + EmailModel.eventEmitter.emit('saved'); + return super.save({ ...email }); + } + + public async needToBeSent(): Promise { + return this.db(this.tableName).where('sent_time', '=', 0); + } + +} diff --git a/packages/server/src/models/TokenModel.test.ts b/packages/server/src/models/TokenModel.test.ts new file mode 100644 index 000000000..aacfee93f --- /dev/null +++ b/packages/server/src/models/TokenModel.test.ts @@ -0,0 +1,30 @@ +import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils'; + +describe('TokenModel', function() { + + beforeAll(async () => { + await beforeAllDb('TokenModel'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should delete old tokens', async function() { + const { user: user1 } = await createUserAndSession(1); + await models().token().generate(user1.id); + + const [token1, token2] = await models().token().all(); + await models().token().save({ id: token1.id, created_time: Date.now() - 2629746000 }); + await models().token().deleteExpiredTokens(); + + const tokens = await models().token().all(); + expect(tokens.length).toBe(1); + expect(tokens[0].id).toBe(token2.id); + }); + +}); diff --git a/packages/server/src/models/TokenModel.ts b/packages/server/src/models/TokenModel.ts new file mode 100644 index 000000000..69f4c7ed3 --- /dev/null +++ b/packages/server/src/models/TokenModel.ts @@ -0,0 +1,62 @@ +import { Token, Uuid } from '../db'; +import { ErrorForbidden } from '../utils/errors'; +import uuidgen from '../utils/uuidgen'; +import BaseModel from './BaseModel'; + +export default class TokenModel extends BaseModel { + + private tokenTtl_: number = 7 * 24 * 60 * 1000; + + public get tableName(): string { + return 'tokens'; + } + + protected hasUuid(): boolean { + return false; + } + + public async generate(userId: Uuid): Promise { + const token = await this.save({ + value: uuidgen(32), + user_id: userId, + }); + + return token.value; + } + + public async checkToken(userId: string, tokenValue: string): Promise { + if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token'); + } + + private async byUser(userId: string, tokenValue: string): Promise { + return this + .db(this.tableName) + .select(['id']) + .where('user_id', '=', userId) + .where('value', '=', tokenValue) + .first(); + } + + public async isValid(userId: string, tokenValue: string): Promise { + const token = await this.byUser(userId, tokenValue); + return !!token; + } + + public async deleteExpiredTokens() { + const cutOffDate = Date.now() - this.tokenTtl_; + await this.db(this.tableName).where('created_time', '<', cutOffDate).delete(); + } + + public async deleteByValue(userId: Uuid, value: string) { + const token = await this.byUser(userId, value); + if (token) await this.delete(token.id); + } + + public async allByUserId(userId: Uuid): Promise { + return this + .db(this.tableName) + .select(this.defaultFields) + .where('user_id', '=', userId); + } + +} diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index 630af6a26..9557e5668 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -1,5 +1,5 @@ import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils'; -import { User } from '../db'; +import { EmailSender, User } from '../db'; import { ErrorUnprocessableEntity } from '../utils/errors'; describe('UserModel', function() { @@ -68,4 +68,22 @@ describe('UserModel', function() { expect((await models().userItem().all()).length).toBe(0); }); + test('should push an email when creating a new user', async function() { + const { user: user1 } = await createUserAndSession(1); + const { user: user2 } = await createUserAndSession(2); + + const emails = await models().email().all(); + expect(emails.length).toBe(2); + expect(emails.find(e => e.recipient_email === user1.email)).toBeTruthy(); + expect(emails.find(e => e.recipient_email === user2.email)).toBeTruthy(); + + const email = emails[0]; + expect(email.subject.trim()).toBeTruthy(); + expect(email.body.includes('/confirm?token=')).toBeTruthy(); + expect(email.sender_id).toBe(EmailSender.NoReply); + expect(email.sent_success).toBe(0); + expect(email.sent_time).toBe(0); + expect(email.error).toBe(''); + }); + }); diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index 9705ade4a..7842aa32c 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -1,7 +1,7 @@ import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel'; -import { Item, User } from '../db'; +import { EmailSender, Item, User, Uuid } from '../db'; import * as auth from '../utils/auth'; -import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge } from '../utils/errors'; +import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors'; import { ModelType } from '@joplin/lib/BaseModel'; import { _ } from '@joplin/lib/locale'; import prettyBytes = require('pretty-bytes'); @@ -134,10 +134,14 @@ export default class UserModel extends BaseModel { return !!s[0].length && !!s[1].length; } - public async profileUrl(): Promise { + public profileUrl(): string { return `${this.baseUrl}/users/me`; } + public confirmUrl(userId: Uuid, validationToken: string): string { + return `${this.baseUrl}/users/${userId}/confirm?token=${validationToken}`; + } + public async delete(id: string): Promise { const shares = await this.models().share().sharesByUser(id); @@ -151,6 +155,13 @@ export default class UserModel extends BaseModel { }, 'UserModel::delete'); } + public async confirmEmail(userId: Uuid, token: string) { + await this.models().token().checkToken(userId, token); + const user = await this.models().user().load(userId); + if (!user) throw new ErrorNotFound('No such user'); + await this.save({ id: user.id, email_confirmed: 1 }); + } + // Note that when the "password" property is provided, it is going to be // hashed automatically. It means that it is not safe to do: // @@ -160,8 +171,30 @@ export default class UserModel extends BaseModel { // Because the password would be hashed twice. public async save(object: User, options: SaveOptions = {}): Promise { const user = { ...object }; + if (user.password) user.password = auth.hashPassword(user.password); - return super.save(user, options); + + const isNew = await this.isNew(object, options); + + return this.withTransaction(async () => { + const savedUser = await super.save(user, options); + + if (isNew) { + const validationToken = await this.models().token().generate(savedUser.id); + const validationUrl = encodeURI(this.confirmUrl(savedUser.id, validationToken)); + + await this.models().email().push({ + sender_id: EmailSender.NoReply, + recipient_id: savedUser.id, + recipient_email: savedUser.email, + recipient_name: savedUser.full_name || '', + subject: 'Verify your email', + body: `Click this: ${validationUrl}`, + }); + } + + return savedUser; + }); } } diff --git a/packages/server/src/models/factory.ts b/packages/server/src/models/factory.ts index 27560ac38..89ff1a6a4 100644 --- a/packages/server/src/models/factory.ts +++ b/packages/server/src/models/factory.ts @@ -63,9 +63,11 @@ import SessionModel from './SessionModel'; import ChangeModel from './ChangeModel'; import NotificationModel from './NotificationModel'; import ShareModel from './ShareModel'; +import EmailModel from './EmailModel'; import ItemResourceModel from './ItemResourceModel'; import ShareUserModel from './ShareUserModel'; import KeyValueModel from './KeyValueModel'; +import TokenModel from './TokenModel'; export class Models { @@ -85,10 +87,18 @@ export class Models { return new UserModel(this.db_, newModelFactory, this.baseUrl_); } + public email() { + return new EmailModel(this.db_, newModelFactory, this.baseUrl_); + } + public userItem() { return new UserItemModel(this.db_, newModelFactory, this.baseUrl_); } + public token() { + return new TokenModel(this.db_, newModelFactory, this.baseUrl_); + } + public itemResource() { return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_); } diff --git a/packages/server/src/routes/api/users.ts b/packages/server/src/routes/api/users.ts index d195c8fb4..275be7509 100644 --- a/packages/server/src/routes/api/users.ts +++ b/packages/server/src/routes/api/users.ts @@ -30,8 +30,9 @@ router.post('api/users', async (_path: SubPath, ctx: AppContext) => { const user = await postedUserFromContext(ctx); // We set a random password because it's required, but user will have to - // set it by clicking on the confirmation link. + // set it after clicking on the confirmation link. user.password = uuidgen(); + user.must_set_password = 1; const output = await ctx.models.user().save(user); return ctx.models.user().toApiOutput(output); }); diff --git a/packages/server/src/routes/index/changes.ts b/packages/server/src/routes/index/changes.ts index 9a5f95ffb..aea7c6f95 100644 --- a/packages/server/src/routes/index/changes.ts +++ b/packages/server/src/routes/index/changes.ts @@ -6,7 +6,7 @@ import { PaginationOrderDir } from '../../models/utils/pagination'; import { formatDateTime } from '../../utils/time'; import defaultView from '../../utils/defaultView'; import { View } from '../../services/MustacheService'; -import { makeTablePagination, Table, Row, makeTableView, tablePartials } from '../../utils/views/table'; +import { makeTablePagination, Table, Row, makeTableView } from '../../utils/views/table'; const router = new Router(); @@ -57,7 +57,6 @@ router.get('changes', async (_path: SubPath, ctx: AppContext) => { const view: View = defaultView('changes'); view.content.changeTable = makeTableView(table), view.cssFiles = ['index/changes']; - view.partials = view.partials.concat(tablePartials()); return view; }); diff --git a/packages/server/src/routes/index/items.ts b/packages/server/src/routes/index/items.ts index 0dc5ebd93..16d2e826e 100644 --- a/packages/server/src/routes/index/items.ts +++ b/packages/server/src/routes/index/items.ts @@ -7,7 +7,7 @@ import config from '../../config'; import { formatDateTime } from '../../utils/time'; import defaultView from '../../utils/defaultView'; import { View } from '../../services/MustacheService'; -import { makeTablePagination, makeTableView, Row, Table, tablePartials } from '../../utils/views/table'; +import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table'; import { PaginationOrderDir } from '../../models/utils/pagination'; const prettyBytes = require('pretty-bytes'); @@ -67,7 +67,6 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => { view.content.itemTable = makeTableView(table), view.content.postUrl = `${config().baseUrl}/items`; view.cssFiles = ['index/items']; - view.partials = view.partials.concat(tablePartials()); return view; }); diff --git a/packages/server/src/routes/index/login.ts b/packages/server/src/routes/index/login.ts index 1d0b9227e..61cb0c398 100644 --- a/packages/server/src/routes/index/login.ts +++ b/packages/server/src/routes/index/login.ts @@ -9,7 +9,7 @@ import { View } from '../../services/MustacheService'; function makeView(error: any = null): View { const view = defaultView('login'); view.content.error = error; - view.partials = ['errorBanner']; + view.navbar = false; return view; } diff --git a/packages/server/src/routes/index/users.test.ts b/packages/server/src/routes/index/users.test.ts index 9c76ca546..a00e62ac5 100644 --- a/packages/server/src/routes/index/users.test.ts +++ b/packages/server/src/routes/index/users.test.ts @@ -1,7 +1,7 @@ import { User } from '../../db'; import routeHandler from '../../middleware/routeHandler'; import { ErrorForbidden } from '../../utils/errors'; -import { execRequest } from '../../utils/testing/apiUtils'; +import { execRequest, execRequestC } from '../../utils/testing/apiUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils'; export async function postUser(sessionId: string, email: string, password: string): Promise { @@ -153,6 +153,76 @@ describe('index_users', function() { expect(result).toContain(user2.email); }); + test('should allow user to set a password for new accounts', async function() { + const { user: user1 } = await createUserAndSession(1); + const { user: user2 } = await createUserAndSession(2); + const email = (await models().email().all()).find(e => e.recipient_id === user1.id); + const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/); + const path = matches[1]; + const token = matches[3]; + + // Check that the token is valid + expect(await models().token().isValid(user1.id, token)).toBe(true); + + // Check that we can't set the password without the token + { + const context = await execRequestC('', 'POST', path, { + password: 'newpassword', + password2: 'newpassword', + }); + const sessionId = context.cookies.get('sessionId'); + expect(sessionId).toBeFalsy(); + } + + // Check that we can't set the password with someone else's token + { + const token2 = (await models().token().allByUserId(user2.id))[0].value; + const context = await execRequestC('', 'POST', path, { + password: 'newpassword', + password2: 'newpassword', + token: token2, + }); + const sessionId = context.cookies.get('sessionId'); + expect(sessionId).toBeFalsy(); + } + + const context = await execRequestC('', 'POST', path, { + password: 'newpassword', + password2: 'newpassword', + token: token, + }); + + // Check that the user has been logged in + const sessionId = context.cookies.get('sessionId'); + const session = await models().session().load(sessionId); + expect(session.user_id).toBe(user1.id); + + // Check that the password has been set + const loggedInUser = await models().user().login(user1.email, 'newpassword'); + expect(loggedInUser.id).toBe(user1.id); + + // Check that the token has been cleared + expect(await models().token().isValid(user1.id, token)).toBe(false); + + // Check that a notification has been created + const notification = (await models().notification().all())[0]; + expect(notification.key).toBe('passwordSet'); + }); + + // test('should handle invalid email validation', async function() { + // await createUserAndSession(1); + // const email = (await models().email().all())[0]; + // const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/); + // const path = matches[1]; + // const token = matches[3]; + + // // Valid path but invalid token + // await expectHttpError(async () => execRequest(null, 'GET', path, null, { query: { token: 'invalid' } }), ErrorNotFound.httpCode); + + // // Valid token but invalid path + // await expectHttpError(async () => execRequest(null, 'GET', 'users/abcd1234/confirm', null, { query: { token } }), ErrorNotFound.httpCode); + // }); + test('should apply ACL', async function() { const { user: admin, session: adminSession } = await createUserAndSession(1, true); const { user: user1, session: session1 } = await createUserAndSession(2, false); diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index f83186525..45075a4a8 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -1,15 +1,26 @@ import { SubPath, redirect } from '../../utils/routeUtils'; import Router from '../../utils/Router'; import { AppContext, HttpMethod } from '../../utils/types'; -import { formParse } from '../../utils/requestUtils'; +import { bodyFields, formParse } from '../../utils/requestUtils'; import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors'; -import { User } from '../../db'; +import { NotificationLevel, User } from '../../db'; import config from '../../config'; import { View } from '../../services/MustacheService'; import defaultView from '../../utils/defaultView'; import { AclAction } from '../../models/BaseModel'; const prettyBytes = require('pretty-bytes'); +function checkPassword(fields: SetPasswordFormData, required: boolean): string { + if (fields.password) { + if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); + return fields.password; + } else { + if (required) throw new ErrorUnprocessableEntity('Password is required'); + } + + return ''; +} + function makeUser(isNew: boolean, fields: any): User { const user: User = {}; @@ -19,10 +30,8 @@ function makeUser(isNew: boolean, fields: any): User { if ('max_item_size' in fields) user.max_item_size = fields.max_item_size; user.can_share = fields.can_share ? 1 : 0; - if (fields.password) { - if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); - user.password = fields.password; - } + const password = checkPassword(fields, false); + if (password) user.password = password; if (!isNew) user.id = fields.id; @@ -83,11 +92,63 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null view.content.error = error; view.content.postUrl = postUrl; view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id; - view.partials.push('errorBanner'); return view; }); +router.publicSchemas.push('users/:id/confirm'); + +router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Error = null) => { + const userId = path.id; + const token = ctx.query.token; + if (token) await ctx.models.user().confirmEmail(userId, token); + + const user = await ctx.models.user().load(userId); + + const view: View = { + ...defaultView('users/confirm'), + content: { + user, + error, + token, + postUrl: ctx.models.user().confirmUrl(userId, token), + }, + navbar: false, + }; + + return view; +}); + +interface SetPasswordFormData { + token: string; + password: string; + password2: string; +} + +router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => { + const userId = path.id; + + try { + const fields = await bodyFields(ctx.req); + await ctx.models.token().checkToken(userId, fields.token); + + const password = checkPassword(fields, true); + + await ctx.models.user().save({ id: userId, password }); + await ctx.models.token().deleteByValue(userId, fields.token); + + const session = await ctx.models.session().createUserSession(userId); + ctx.cookies.set('sessionId', session.id); + + await ctx.models.notification().add(userId, 'passwordSet', NotificationLevel.Normal, 'Welcome to Joplin Cloud! Your password has been set successfully.'); + + return redirect(ctx, `${config().baseUrl}/home`); + } catch (error) { + const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id/confirm'); + return endPoint(path, ctx, error); + } +}); + router.alias(HttpMethod.POST, 'users/:id', 'users'); router.post('users', async (path: SubPath, ctx: AppContext) => { diff --git a/packages/server/src/services/BaseApplication.ts b/packages/server/src/services/BaseApplication.ts deleted file mode 100644 index 73a9b49d5..000000000 --- a/packages/server/src/services/BaseApplication.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Models } from '../models/factory'; -import { Config } from '../utils/types'; -import MustacheService from './MustacheService'; - -export default class BaseApplication { - - private appName_: string; - private config_: Config = null; - private models_: Models = null; - private mustache_: MustacheService = null; - private rootDir_: string; - - protected get mustache(): MustacheService { - return this.mustache_; - } - - protected get config(): Config { - return this.config_; - } - - protected get models(): Models { - return this.models_; - } - - public get rootDir(): string { - return this.rootDir_; - } - - public get appBaseUrl(): string { - return `${this.config.baseUrl}/apps/${this.appName_}`; - } - - public initBase_(appName: string, config: Config, models: Models) { - this.appName_ = appName; - this.rootDir_ = `${config.rootDir}/src/apps/${appName}`; - this.config_ = config; - this.models_ = models; - this.mustache_ = new MustacheService(`${this.rootDir}/views`, `${config.baseUrl}/apps/${appName}`); - } - - public async localFileFromUrl(_url: string): Promise { - return null; - } - -} diff --git a/packages/server/src/services/BaseService.ts b/packages/server/src/services/BaseService.ts new file mode 100644 index 000000000..9d7351645 --- /dev/null +++ b/packages/server/src/services/BaseService.ts @@ -0,0 +1,96 @@ +import Logger from '@joplin/lib/Logger'; +import { Models } from '../models/factory'; +import { msleep } from '../utils/time'; +import { Config, Env } from '../utils/types'; + +const logger = Logger.create('BaseService'); + +export default class BaseService { + + private env_: Env; + private models_: Models; + private config_: Config; + protected enabled_: boolean = true; + private destroyed_: boolean = false; + protected maintenanceInterval_: number = 10000; + private scheduledMaintenances_: boolean[] = []; + private maintenanceInProgress_: boolean = false; + + public constructor(env: Env, models: Models, config: Config) { + this.env_ = env; + this.models_ = models; + this.config_ = config; + this.scheduleMaintenance = this.scheduleMaintenance.bind(this); + } + + public async destroy() { + if (this.destroyed_) throw new Error('Already destroyed'); + this.destroyed_ = true; + this.scheduledMaintenances_ = []; + + while (this.maintenanceInProgress_) { + await msleep(500); + } + } + + protected get models(): Models { + return this.models_; + } + + protected get env(): Env { + return this.env_; + } + + protected get config(): Config { + return this.config_; + } + + public get enabled(): boolean { + return this.enabled_; + } + + public get maintenanceInProgress(): boolean { + return !!this.scheduledMaintenances_.length; + } + + protected async scheduleMaintenance() { + if (this.destroyed_) return; + + // Every time a maintenance is scheduled we push a task to this array. + // Whenever the maintenance actually runs, that array is cleared. So it + // means, that if new tasks are pushed to the array while the + // maintenance is runing, it will run again once it's finished, so as to + // process any item that might have been added. + + this.scheduledMaintenances_.push(true); + + if (this.scheduledMaintenances_.length !== 1) return; + + while (this.scheduledMaintenances_.length) { + await msleep(this.env === Env.Dev ? 2000 : this.maintenanceInterval_); + if (this.destroyed_) return; + const itemCount = this.scheduledMaintenances_.length; + await this.runMaintenance(); + this.scheduledMaintenances_.splice(0, itemCount); + } + } + + private async runMaintenance() { + this.maintenanceInProgress_ = true; + try { + await this.maintenance(); + } catch (error) { + logger.error('Could not run maintenance', error); + } + this.maintenanceInProgress_ = false; + } + + protected async maintenance() { + throw new Error('Not implemented'); + } + + public async runInBackground() { + await this.runMaintenance(); + } + +} diff --git a/packages/server/src/services/CronService.ts b/packages/server/src/services/CronService.ts new file mode 100644 index 000000000..53a08eef2 --- /dev/null +++ b/packages/server/src/services/CronService.ts @@ -0,0 +1,12 @@ +import BaseService from './BaseService'; +const cron = require('node-cron'); + +export default class CronService extends BaseService { + + public async runInBackground() { + cron.schedule('0 */6 * * *', async () => { + await this.models.token().deleteExpiredTokens(); + }); + } + +} diff --git a/packages/server/src/services/EmailService.ts b/packages/server/src/services/EmailService.ts new file mode 100644 index 000000000..1455785db --- /dev/null +++ b/packages/server/src/services/EmailService.ts @@ -0,0 +1,122 @@ +import Logger from '@joplin/lib/Logger'; +import UserModel from '../models/UserModel'; +import BaseService from './BaseService'; +import Mail = require('nodemailer/lib/mailer'); +import { createTransport } from 'nodemailer'; +import { Email, EmailSender } from '../db'; +import { errorToString } from '../utils/errors'; + +const logger = Logger.create('EmailService'); + +interface Participant { + name: string; + email: string; +} + +export default class EmailService extends BaseService { + + private transport_: any; + + private async transport(): Promise { + if (!this.transport_) { + this.transport_ = createTransport({ + host: this.config.mailer.host, + port: this.config.mailer.port, + secure: this.config.mailer.secure, + auth: { + user: this.config.mailer.authUser, + pass: this.config.mailer.authPassword, + }, + }); + + try { + await this.transport_.verify(); + } catch (error) { + this.enabled_ = false; + this.transport_ = null; + error.message = `Could not initialize transporter. Service will be disabled: ${error.message}`; + throw error; + } + } + + return this.transport_; + } + + private senderInfo(senderId: EmailSender): Participant { + if (senderId === EmailSender.NoReply) { + return { + name: this.config.mailer.noReplyName, + email: this.config.mailer.noReplyEmail, + }; + } + + throw new Error(`Invalid sender ID: ${senderId}`); + } + + private escapeEmailField(f: string): string { + return f.replace(/[\n\r"<>]/g, ''); + } + + private formatNameAndEmail(email: string, name: string = ''): string { + if (!email) throw new Error('Email is required'); + const output: string[] = []; + if (name) output.push(`"${this.escapeEmailField(name)}"`); + output.push((name ? '<' : '') + this.escapeEmailField(email) + (name ? '>' : '')); + return output.join(' '); + } + + protected async maintenance() { + if (!this.enabled_) return; + + logger.info('Starting maintenance...'); + const startTime = Date.now(); + + try { + const emails = await this.models.email().needToBeSent(); + const transport = await this.transport(); + + for (const email of emails) { + const sender = this.senderInfo(email.sender_id); + + const mailOptions: Mail.Options = { + from: this.formatNameAndEmail(sender.email, sender.name), + to: this.formatNameAndEmail(email.recipient_email, email.recipient_name), + subject: email.subject, + text: email.body, + }; + + const emailToSave: Email = { + id: email.id, + sent_time: Date.now(), + }; + + try { + await transport.sendMail(mailOptions); + emailToSave.sent_success = 1; + emailToSave.error = ''; + } catch (error) { + emailToSave.sent_success = 0; + emailToSave.error = errorToString(error); + } + + await this.models.email().save(emailToSave); + } + } catch (error) { + logger.error('Could not run maintenance:', error); + } + + logger.info(`Maintenance completed in ${Date.now() - startTime}ms`); + } + + public async runInBackground() { + if (!this.config.mailer.host || !this.config.mailer.enabled) { + this.enabled_ = false; + logger.info('Service will be disabled because mailer config is not set or is explicitly disabled'); + return; + } + + UserModel.eventEmitter.on('created', this.scheduleMaintenance); + await super.runInBackground(); + } + +} diff --git a/packages/server/src/services/MustacheService.ts b/packages/server/src/services/MustacheService.ts index 630eff615..3d0189fbd 100644 --- a/packages/server/src/services/MustacheService.ts +++ b/packages/server/src/services/MustacheService.ts @@ -1,6 +1,9 @@ import * as Mustache from 'mustache'; import * as fs from 'fs-extra'; import config from '../config'; +import { filename } from '@joplin/lib/path-utils'; +import { NotificationView } from '../utils/types'; +import { User } from '../db'; export interface RenderOptions { partials?: any; @@ -11,12 +14,21 @@ export interface RenderOptions { export interface View { name: string; path: string; + navbar?: boolean; content?: any; partials?: string[]; cssFiles?: string[]; jsFiles?: string[]; } +interface GlobalParams { + baseUrl?: string; + prefersDarkEnabled?: boolean; + notifications?: NotificationView[]; + hasNotifications?: boolean; + owner?: User; +} + export function isView(o: any): boolean { if (typeof o !== 'object' || !o) return false; return 'path' in o && 'name' in o; @@ -27,12 +39,27 @@ export default class MustacheService { private viewDir_: string; private baseAssetUrl_: string; private prefersDarkEnabled_: boolean = true; + private partials_: Record = {}; public constructor(viewDir: string, baseAssetUrl: string) { this.viewDir_ = viewDir; this.baseAssetUrl_ = baseAssetUrl; } + public async loadPartials() { + + const files = await fs.readdir(this.partialDir); + for (const f of files) { + const name = filename(f); + const templateContent = await this.loadTemplateContent(`${this.partialDir}/${f}`); + this.partials_[name] = templateContent; + } + } + + public get partialDir(): string { + return `${this.viewDir_}/partials`; + } + public get prefersDarkEnabled(): boolean { return this.prefersDarkEnabled_; } @@ -45,7 +72,7 @@ export default class MustacheService { return `${config().layoutDir}/default.mustache`; } - private get defaultLayoutOptions(): any { + private get defaultLayoutOptions(): GlobalParams { return { baseUrl: config().baseUrl, prefersDarkEnabled: this.prefersDarkEnabled_, @@ -64,17 +91,9 @@ export default class MustacheService { return output; } - public async renderView(view: View, globalParams: any = null): Promise { - const partials = view.partials || []; + public async renderView(view: View, globalParams: GlobalParams = null): Promise { const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []); const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []); - - const partialContents: any = {}; - for (const partialName of partials) { - const filePath = `${this.viewDir_}/partials/${partialName}.mustache`; - partialContents[partialName] = await this.loadTemplateContent(filePath); - } - const filePath = `${this.viewDir_}/${view.path}.mustache`; globalParams = { @@ -88,19 +107,20 @@ export default class MustacheService { ...view.content, global: globalParams, }, - partialContents + this.partials_ ); - const layoutView: any = Object.assign({}, { + const layoutView: any = { global: globalParams, pageName: view.name, contentHtml: contentHtml, cssFiles: cssFiles, jsFiles: jsFiles, + navbar: view.navbar, ...view.content, - }); + }; - return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents); + return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, this.partials_); } } diff --git a/packages/server/src/services/ShareService.test.ts b/packages/server/src/services/ShareService.test.ts index d66c167d9..ea4cd1457 100644 --- a/packages/server/src/services/ShareService.test.ts +++ b/packages/server/src/services/ShareService.test.ts @@ -1,3 +1,4 @@ +import config from '../config'; import { shareFolderWithUser } from '../utils/testing/shareApiUtils'; import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, updateNote, msleep } from '../utils/testing/testUtils'; import { Env } from '../utils/types'; @@ -23,7 +24,7 @@ describe('ShareService', function() { const { user: user1, session: session1 } = await createUserAndSession(1); const { user: user2, session: session2 } = await createUserAndSession(2); - const service = new ShareService(Env.Dev, models()); + const service = new ShareService(Env.Dev, models(), config()); void service.runInBackground(); await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', { diff --git a/packages/server/src/services/ShareService.ts b/packages/server/src/services/ShareService.ts index 9bac11780..7127475ea 100644 --- a/packages/server/src/services/ShareService.ts +++ b/packages/server/src/services/ShareService.ts @@ -1,73 +1,27 @@ import Logger from '@joplin/lib/Logger'; import ChangeModel from '../models/ChangeModel'; -import { Models } from '../models/factory'; -import { Env } from '../utils/types'; +import BaseService from './BaseService'; const logger = Logger.create('ShareService'); -export default class ShareService { - - private env_: Env; - private models_: Models; - private maintenanceScheduled_: boolean = false; - private maintenanceInProgress_: boolean = false; - private scheduleMaintenanceTimeout_: any = null; - - public constructor(env: Env, models: Models) { - this.env_ = env; - this.models_ = models; - this.scheduleMaintenance = this.scheduleMaintenance.bind(this); - } - - public async destroy() { - if (this.scheduleMaintenanceTimeout_) { - clearTimeout(this.scheduleMaintenanceTimeout_); - this.scheduleMaintenanceTimeout_ = null; - } - } - - public get models(): Models { - return this.models_; - } - - public get env(): Env { - return this.env_; - } - - public get maintenanceInProgress(): boolean { - return this.maintenanceInProgress_; - } - - private async scheduleMaintenance() { - if (this.maintenanceScheduled_) return; - this.maintenanceScheduled_ = true; - - this.scheduleMaintenanceTimeout_ = setTimeout(() => { - this.maintenanceScheduled_ = false; - void this.maintenance(); - }, this.env === Env.Dev ? 2000 : 10000); - } - - private async maintenance() { - if (this.maintenanceInProgress_) return; +export default class ShareService extends BaseService { + protected async maintenance() { logger.info('Starting maintenance...'); const startTime = Date.now(); - this.maintenanceInProgress_ = true; try { await this.models.share().updateSharedItems3(); } catch (error) { logger.error('Could not update share items:', error); } - this.maintenanceInProgress_ = false; logger.info(`Maintenance completed in ${Date.now() - startTime}ms`); } public async runInBackground() { ChangeModel.eventEmitter.on('saved', this.scheduleMaintenance); - await this.maintenance(); + await super.runInBackground(); } } diff --git a/packages/server/src/services/types.ts b/packages/server/src/services/types.ts index ec67409c1..f30793eee 100644 --- a/packages/server/src/services/types.ts +++ b/packages/server/src/services/types.ts @@ -1,5 +1,11 @@ +import CronService from './CronService'; +import EmailService from './EmailService'; +import MustacheService from './MustacheService'; import ShareService from './ShareService'; export interface Services { share: ShareService; + email: EmailService; + cron: CronService; + mustache: MustacheService; } diff --git a/packages/server/src/tools/generateTypes.ts b/packages/server/src/tools/generateTypes.ts index 8102b0212..3ed91e388 100644 --- a/packages/server/src/tools/generateTypes.ts +++ b/packages/server/src/tools/generateTypes.ts @@ -31,6 +31,8 @@ const config = { 'main.shares': 'WithDates, WithUuid', 'main.share_users': 'WithDates, WithUuid', 'main.user_items': 'WithDates', + 'main.emails': 'WithDates', + 'main.tokens': 'WithDates', }, }; @@ -41,6 +43,8 @@ const propertyTypes: Record = { 'shares.type': 'ShareType', 'items.content': 'Buffer', 'share_users.status': 'ShareUserStatus', + 'emails.sender_id': 'EmailSender', + 'emails.sent_time': 'number', }; function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void { diff --git a/packages/server/src/utils/Router.ts b/packages/server/src/utils/Router.ts index 02a67121c..3ce24a104 100644 --- a/packages/server/src/utils/Router.ts +++ b/packages/server/src/utils/Router.ts @@ -9,6 +9,7 @@ export default class Router { // not logged in, can access any route of this router. End points that // should not be publicly available should call ownerRequired(ctx); public public: boolean = false; + public publicSchemas: string[] = []; public responseFormat: RouteResponseFormat = null; @@ -34,6 +35,10 @@ export default class Router { throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`); } + public isPublic(schema: string): boolean { + return this.public || this.publicSchemas.includes(schema); + } + public alias(method: HttpMethod, path: string, target: string) { if (!this.aliases_[method]) { this.aliases_[method] = {}; } this.aliases_[method][path] = target; diff --git a/packages/server/src/utils/defaultView.ts b/packages/server/src/utils/defaultView.ts index 077610d73..8a91215e0 100644 --- a/packages/server/src/utils/defaultView.ts +++ b/packages/server/src/utils/defaultView.ts @@ -6,9 +6,6 @@ export default function(name: string): View { name: name, path: `index/${name}`, content: {}, - partials: [ - 'navbar', - 'notifications', - ], + navbar: true, }; } diff --git a/packages/server/src/utils/errors.ts b/packages/server/src/utils/errors.ts index b7d21daa4..050696ee0 100644 --- a/packages/server/src/utils/errors.ts +++ b/packages/server/src/utils/errors.ts @@ -86,3 +86,10 @@ export class ErrorPayloadTooLarge extends ApiError { Object.setPrototypeOf(this, ErrorPayloadTooLarge.prototype); } } + +export function errorToString(error: Error): string { + const msg: string[] = []; + msg.push(error.message ? error.message : 'Unknown error'); + if (error.stack) msg.push(error.stack); + return msg.join(': '); +} diff --git a/packages/server/src/utils/joplinUtils.ts b/packages/server/src/utils/joplinUtils.ts index 43ed22050..0d14d9a02 100644 --- a/packages/server/src/utils/joplinUtils.ts +++ b/packages/server/src/utils/joplinUtils.ts @@ -1,5 +1,5 @@ import JoplinDatabase from '@joplin/lib/JoplinDatabase'; -import Logger from '@joplin/lib/Logger'; +// import Logger from '@joplin/lib/Logger'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import BaseItem from '@joplin/lib/models/BaseItem'; import Note from '@joplin/lib/models/Note'; @@ -24,7 +24,7 @@ import Setting from '@joplin/lib/models/Setting'; import { Models } from '../models/factory'; import MustacheService from '../services/MustacheService'; -const logger = Logger.create('JoplinUtils'); +// const logger = Logger.create('JoplinUtils'); export interface FileViewerResponse { body: any; @@ -55,15 +55,16 @@ let baseUrl_: string = null; export const resourceDirName = '.resource'; -export async function initializeJoplinUtils(config: Config, models: Models) { +export async function initializeJoplinUtils(config: Config, models: Models, mustache: MustacheService) { models_ = models; baseUrl_ = config.baseUrl; + mustache_ = mustache; const filePath = `${config.tempDir}/joplin.sqlite`; await fs.remove(filePath); db_ = new JoplinDatabase(new DatabaseDriverNode()); - db_.setLogger(logger as Logger); + // db_.setLogger(logger as Logger); await db_.open({ name: filePath }); BaseModel.setDb(db_); @@ -78,8 +79,8 @@ export async function initializeJoplinUtils(config: Config, models: Models) { BaseItem.loadClass('MasterKey', MasterKey); BaseItem.loadClass('Revision', Revision); - mustache_ = new MustacheService(config.viewDir, config.baseUrl); - mustache_.prefersDarkEnabled = false; + // mustache_ = new MustacheService(config.viewDir, config.baseUrl); + // mustache_.prefersDarkEnabled = false; } export function linkedResourceIds(body: string): string[] { @@ -210,7 +211,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc }; `, }, - }); + }, { prefersDarkEnabled: false }); return { body: bodyHtml, diff --git a/packages/server/src/utils/requestUtils.ts b/packages/server/src/utils/requestUtils.ts index 8743704a1..e2838195f 100644 --- a/packages/server/src/utils/requestUtils.ts +++ b/packages/server/src/utils/requestUtils.ts @@ -22,6 +22,8 @@ export async function formParse(req: any): Promise { return output; } + // Note that for Formidable to work, the content-type must be set in the + // headers return new Promise((resolve: Function, reject: Function) => { const form = formidable({ multiples: true }); form.parse(req, (error: any, fields: any, files: any) => { @@ -36,29 +38,8 @@ export async function formParse(req: any): Promise { } export async function bodyFields(req: any/* , filter:string[] = null*/): Promise { - // Formidable needs the content-type to be 'application/json' so on our side - // we explicitely set it to that. However save the previous value so that it - // can be restored. - let previousContentType = null; - if (req.headers['content-type'] !== 'application/json') { - previousContentType = req.headers['content-type']; - req.headers['content-type'] = 'application/json'; - } - const form = await formParse(req); - if (previousContentType) req.headers['content-type'] = previousContentType; - return form.fields as T; - - // if (filter) { - // const output:BodyFields = {}; - // Object.keys(form.fields).forEach(f => { - // if (filter.includes(f)) output[f] = form.fields[f]; - // }); - // return output; - // } else { - // return form.fields; - // } } export function ownerRequired(ctx: AppContext) { diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index cdb91cb02..2bbba23f3 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -171,7 +171,7 @@ export async function execRequest(routes: Routers, ctx: AppContext) { // This is a generic catch-all for all private end points - if we // couldn't get a valid session, we exit now. Individual end points // might have additional permission checks depending on the action. - if (!match.route.public && !ctx.owner) throw new ErrorForbidden(); + if (!match.route.isPublic(match.subPath.schema) && !ctx.owner) throw new ErrorForbidden(); return routeHandler(match.subPath, ctx); } diff --git a/packages/server/src/utils/setupAppContext.ts b/packages/server/src/utils/setupAppContext.ts index 3673b82fd..18268f83d 100644 --- a/packages/server/src/utils/setupAppContext.ts +++ b/packages/server/src/utils/setupAppContext.ts @@ -2,22 +2,32 @@ import { LoggerWrapper } from '@joplin/lib/Logger'; import config from '../config'; import { DbConnection } from '../db'; import newModelFactory, { Models } from '../models/factory'; -import { AppContext, Env } from './types'; +import { AppContext, Config, Env } from './types'; import routes from '../routes/routes'; import ShareService from '../services/ShareService'; import { Services } from '../services/types'; +import EmailService from '../services/EmailService'; +import CronService from '../services/CronService'; +import MustacheService from '../services/MustacheService'; -function setupServices(env: Env, models: Models): Services { - return { - share: new ShareService(env, models), +async function setupServices(env: Env, models: Models, config: Config): Promise { + const output: Services = { + share: new ShareService(env, models, config), + email: new EmailService(env, models, config), + cron: new CronService(env, models, config), + mustache: new MustacheService(config.viewDir, config.baseUrl), }; + + await output.mustache.loadPartials(); + + return output; } export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) { appContext.env = env; appContext.db = dbConnection; appContext.models = newModelFactory(appContext.db, config().baseUrl); - appContext.services = setupServices(env, appContext.models); + appContext.services = await setupServices(env, appContext.models, config()); appContext.appLogger = appLogger; appContext.routes = { ...routes }; diff --git a/packages/server/src/utils/startServices.ts b/packages/server/src/utils/startServices.ts index 796a05a81..e59a815ac 100644 --- a/packages/server/src/utils/startServices.ts +++ b/packages/server/src/utils/startServices.ts @@ -1,5 +1,9 @@ import { AppContext } from './types'; -export default function startServices(appContext: AppContext) { - void appContext.services.share.runInBackground(); +export default async function startServices(appContext: AppContext) { + const services = appContext.services; + + void services.share.runInBackground(); + void services.email.runInBackground(); + void services.cron.runInBackground(); } diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts index 82991503c..7925eb14a 100644 --- a/packages/server/src/utils/testing/testUtils.ts +++ b/packages/server/src/utils/testing/testUtils.ts @@ -17,6 +17,7 @@ import { putApi } from './apiUtils'; import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; import { ModelType } from '@joplin/lib/BaseModel'; import { initializeJoplinUtils } from '../joplinUtils'; +import MustacheService from '../../services/MustacheService'; // Takes into account the fact that this file will be inside the /dist directory // when it runs. @@ -84,7 +85,10 @@ export async function beforeAllDb(unitName: string) { await createDb(config().database, { dropIfExists: true }); db_ = await connectDb(config().database); - await initializeJoplinUtils(config(), models()); + const mustache = new MustacheService(config().viewDir, config().baseUrl); + await mustache.loadPartials(); + + await initializeJoplinUtils(config(), models(), mustache); } export async function afterAllTests() { diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index e01239832..451ced808 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -44,17 +44,29 @@ export interface DatabaseConfig { asyncStackTraces?: boolean; } +export interface MailerConfig { + enabled: boolean; + host: string; + port: number; + secure: boolean; + authUser: string; + authPassword: string; + noReplyName: string; + noReplyEmail: string; +} + export interface Config { port: number; rootDir: string; viewDir: string; layoutDir: string; - // Not that, for now, nothing is being logged to file. Log is just printed + // Note that, for now, nothing is being logged to file. Log is just printed // to stdout, which is then handled by Docker own log mechanism logDir: string; tempDir: string; - database: DatabaseConfig; baseUrl: string; + database: DatabaseConfig; + mailer: MailerConfig; } export enum HttpMethod { diff --git a/packages/server/src/views/index/error.mustache b/packages/server/src/views/index/error.mustache index 74630390a..bf9f4603d 100644 --- a/packages/server/src/views/index/error.mustache +++ b/packages/server/src/views/index/error.mustache @@ -7,10 +7,10 @@ {{/stack}} {{#owner}} -

Back to home page

+

Go to home page

{{/owner}} {{^owner}} -

Back to login page

+

Go to login page

{{/owner}} \ No newline at end of file diff --git a/packages/server/src/views/index/users/confirm.mustache b/packages/server/src/views/index/users/confirm.mustache new file mode 100644 index 000000000..94e652f48 --- /dev/null +++ b/packages/server/src/views/index/users/confirm.mustache @@ -0,0 +1,33 @@ +
+
+

Welcome to Joplin!

+

Please enter your password to start using your account.

+ +
+ + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {{> errorBanner}} +
+ +
+
+
+
diff --git a/packages/server/src/views/partials/errorBanner.mustache b/packages/server/src/views/partials/errorBanner.mustache index 4c80c6a7d..ddffcadd1 100644 --- a/packages/server/src/views/partials/errorBanner.mustache +++ b/packages/server/src/views/partials/errorBanner.mustache @@ -1,8 +1,8 @@ {{#error}}
- {{error.message}} - {{#error.stack}} + {{message}} + {{#stack}}
{{.}}
- {{/error.stack}} + {{/stack}}
{{/error}} \ No newline at end of file diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index 241f4a507..1ba83588c 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -1,26 +1,28 @@ - +{{/navbar}} \ No newline at end of file