1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Server: Add mailer service

This commit is contained in:
Laurent Cozic 2021-05-25 11:49:47 +02:00
parent 68b516998d
commit ed8ee67048
51 changed files with 960 additions and 235 deletions

View File

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

View File

@ -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.
*/

View File

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

View File

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

View File

@ -6,4 +6,5 @@ db-*.sqlite
*.pid
logs/
tests/temp/
temp/
temp/
.env

View File

@ -1,4 +1,9 @@
{
"verbose": true,
"watch": ["dist/", "../renderer", "../lib"]
"watch": [
"dist/",
"../renderer",
"../lib",
"src/views"
]
}

View File

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

View File

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

Binary file not shown.

View File

@ -67,11 +67,22 @@ function markPasswords(o: Record<string, any>): Record<string, any> {
return output;
}
async function main() {
if (argv.envFile) {
nodeEnvFile(argv.envFile);
async function getEnvFilePath(env: Env, argv: any): Promise<string> {
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
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<any> {
}

View File

@ -272,10 +272,10 @@ export default abstract class BaseModel<T> {
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
}
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
public async delete(id: string | string[] | number | number[], options: DeleteOptions = {}): Promise<void> {
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');

View File

@ -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<Email> {
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<Email[]> {
return this.db(this.tableName).where('sent_time', '=', 0);
}
}

View File

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

View File

@ -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<Token> {
private tokenTtl_: number = 7 * 24 * 60 * 1000;
public get tableName(): string {
return 'tokens';
}
protected hasUuid(): boolean {
return false;
}
public async generate(userId: Uuid): Promise<string> {
const token = await this.save({
value: uuidgen(32),
user_id: userId,
});
return token.value;
}
public async checkToken(userId: string, tokenValue: string): Promise<void> {
if (!(await this.isValid(userId, tokenValue))) throw new ErrorForbidden('Invalid or expired token');
}
private async byUser(userId: string, tokenValue: string): Promise<Token> {
return this
.db(this.tableName)
.select(['id'])
.where('user_id', '=', userId)
.where('value', '=', tokenValue)
.first();
}
public async isValid(userId: string, tokenValue: string): Promise<boolean> {
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<Token[]> {
return this
.db(this.tableName)
.select(this.defaultFields)
.where('user_id', '=', userId);
}
}

View File

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

View File

@ -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<User> {
return !!s[0].length && !!s[1].length;
}
public async profileUrl(): Promise<string> {
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<void> {
const shares = await this.models().share().sharesByUser(id);
@ -151,6 +155,13 @@ export default class UserModel extends BaseModel<User> {
}, '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<User> {
// Because the password would be hashed twice.
public async save(object: User, options: SaveOptions = {}): Promise<User> {
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;
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<User> {
@ -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);

View File

@ -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<SetPasswordFormData>(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) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, string> = {};
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<string> {
const partials = view.partials || [];
public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
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_);
}
}

View File

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

View File

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

View File

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

View File

@ -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<string, string> = {
'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 {

View File

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

View File

@ -6,9 +6,6 @@ export default function(name: string): View {
name: name,
path: `index/${name}`,
content: {},
partials: [
'navbar',
'notifications',
],
navbar: true,
};
}

View File

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

View File

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

View File

@ -22,6 +22,8 @@ export async function formParse(req: any): Promise<FormParseResult> {
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<FormParseResult> {
}
export async function bodyFields<T>(req: any/* , filter:string[] = null*/): Promise<T> {
// 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) {

View File

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

View File

@ -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<Services> {
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 };

View File

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

View File

@ -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() {

View File

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

View File

@ -7,10 +7,10 @@
{{/stack}}
</div>
{{#owner}}
<p><a href="{{{global.baseUrl}}}/home">Back to home page</a></p>
<p><a href="{{{global.baseUrl}}}/home">Go to home page</a></p>
{{/owner}}
{{^owner}}
<p><a href="{{{global.baseUrl}}}/login">Back to login page</a></p>
<p><a href="{{{global.baseUrl}}}/login">Go to login page</a></p>
{{/owner}}
</div>
</div>

View File

@ -0,0 +1,33 @@
<section class="section">
<div class="container">
<h1 class="title">Welcome to Joplin!</h1>
<h2 class="subtitle">Please enter your password to start using your account.</h2>
<form action="{{postUrl}}" method="POST">
<input class="input" type="hidden" name="token" value="{{token}}"/>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" disabled value="{{user.email}}"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password"/>
</div>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2"/>
</div>
</div>
{{> errorBanner}}
<div class="control">
<button class="button is-primary">Save password</button>
</div>
</form>
</div>
</section>

View File

@ -1,8 +1,8 @@
{{#error}}
<div class="notification is-danger">
<strong>{{error.message}}</strong>
{{#error.stack}}
<strong>{{message}}</strong>
{{#stack}}
<pre>{{.}}</pre>
{{/error.stack}}
{{/stack}}
</div>
{{/error}}

View File

@ -1,26 +1,28 @@
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
{{#navbar}}
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
{{/global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
</div>
</div>
</div>
</div>
</nav>
</nav>
{{/navbar}}