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:
parent
68b516998d
commit
ed8ee67048
@ -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"
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@ -6,4 +6,5 @@ db-*.sqlite
|
||||
*.pid
|
||||
logs/
|
||||
tests/temp/
|
||||
temp/
|
||||
temp/
|
||||
.env
|
@ -1,4 +1,9 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"watch": ["dist/", "../renderer", "../lib"]
|
||||
"watch": [
|
||||
"dist/",
|
||||
"../renderer",
|
||||
"../lib",
|
||||
"src/views"
|
||||
]
|
||||
}
|
35
packages/server/package-lock.json
generated
35
packages/server/package-lock.json
generated
@ -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",
|
||||
|
@ -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.
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
@ -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 };
|
||||
|
47
packages/server/src/migrations/20210518172311_mailer.ts
Normal file
47
packages/server/src/migrations/20210518172311_mailer.ts
Normal 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> {
|
||||
|
||||
}
|
@ -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');
|
||||
|
||||
|
33
packages/server/src/models/EmailModel.ts
Normal file
33
packages/server/src/models/EmailModel.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
30
packages/server/src/models/TokenModel.test.ts
Normal file
30
packages/server/src/models/TokenModel.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
62
packages/server/src/models/TokenModel.ts
Normal file
62
packages/server/src/models/TokenModel.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@ -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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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_);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
96
packages/server/src/services/BaseService.ts
Normal file
96
packages/server/src/services/BaseService.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
12
packages/server/src/services/CronService.ts
Normal file
12
packages/server/src/services/CronService.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
122
packages/server/src/services/EmailService.ts
Normal file
122
packages/server/src/services/EmailService.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
@ -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_);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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', {
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -6,9 +6,6 @@ export default function(name: string): View {
|
||||
name: name,
|
||||
path: `index/${name}`,
|
||||
content: {},
|
||||
partials: [
|
||||
'navbar',
|
||||
'notifications',
|
||||
],
|
||||
navbar: true,
|
||||
};
|
||||
}
|
||||
|
@ -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(': ');
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
33
packages/server/src/views/index/users/confirm.mustache
Normal file
33
packages/server/src/views/index/users/confirm.mustache
Normal 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>
|
@ -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}}
|
@ -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}}
|
Loading…
Reference in New Issue
Block a user