You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-23 23:33:01 +02:00
Compare commits
8 Commits
server_mai
...
testing_up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a793270d | ||
|
|
5f8b73350d | ||
|
|
a733cb0394 | ||
|
|
7da4cb0d80 | ||
|
|
3233082b4c | ||
|
|
c782f5e981 | ||
|
|
db51e2dd4b | ||
|
|
f00f3d7f04 |
@@ -83,9 +83,6 @@ packages/app-cli/tests/MdToHtml.js.map
|
||||
packages/app-cli/tests/MdToMd.d.ts
|
||||
packages/app-cli/tests/MdToMd.js
|
||||
packages/app-cli/tests/MdToMd.js.map
|
||||
packages/app-cli/tests/services/keychain/KeychainService.d.ts
|
||||
packages/app-cli/tests/services/keychain/KeychainService.js
|
||||
packages/app-cli/tests/services/keychain/KeychainService.js.map
|
||||
packages/app-cli/tests/services/plugins/PluginService.d.ts
|
||||
packages/app-cli/tests/services/plugins/PluginService.js
|
||||
packages/app-cli/tests/services/plugins/PluginService.js.map
|
||||
@@ -1169,6 +1166,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
|
||||
packages/lib/services/keychain/keychainService.test.d.ts
|
||||
packages/lib/services/keychain/keychainService.test.js
|
||||
packages/lib/services/keychain/keychainService.test.js.map
|
||||
packages/lib/services/plugins/BasePluginRunner.d.ts
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js.map
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -69,9 +69,6 @@ packages/app-cli/tests/MdToHtml.js.map
|
||||
packages/app-cli/tests/MdToMd.d.ts
|
||||
packages/app-cli/tests/MdToMd.js
|
||||
packages/app-cli/tests/MdToMd.js.map
|
||||
packages/app-cli/tests/services/keychain/KeychainService.d.ts
|
||||
packages/app-cli/tests/services/keychain/KeychainService.js
|
||||
packages/app-cli/tests/services/keychain/KeychainService.js.map
|
||||
packages/app-cli/tests/services/plugins/PluginService.d.ts
|
||||
packages/app-cli/tests/services/plugins/PluginService.js
|
||||
packages/app-cli/tests/services/plugins/PluginService.js.map
|
||||
@@ -1155,6 +1152,9 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js.map
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.d.ts
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js.map
|
||||
packages/lib/services/keychain/keychainService.test.d.ts
|
||||
packages/lib/services/keychain/keychainService.test.js
|
||||
packages/lib/services/keychain/keychainService.test.js.map
|
||||
packages/lib/services/plugins/BasePluginRunner.d.ts
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js.map
|
||||
|
||||
@@ -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"}' -H 'Content-Type: application/json' http://localhost:22300/api/debug
|
||||
curl --data '{"action": "createTestUsers"}' http://localhost:22300/api/debug
|
||||
|
||||
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
const { afterEachCleanUp } = require('./testing/test-utils.js');
|
||||
const { shimInit } = require('./shim-init-node.js');
|
||||
const shim = require('./shim').default;
|
||||
const sharp = require('sharp');
|
||||
|
||||
shimInit(sharp, null);
|
||||
let keytar;
|
||||
try {
|
||||
keytar = shim.platformSupportsKeyChain() ? require('keytar') : null;
|
||||
} catch (error) {
|
||||
console.error('Cannot load keytar - keychain support will be disabled', error);
|
||||
keytar = null;
|
||||
}
|
||||
|
||||
shimInit(sharp, keytar);
|
||||
|
||||
global.afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
|
||||
18
packages/lib/package-lock.json
generated
18
packages/lib/package-lock.json
generated
@@ -4059,6 +4059,24 @@
|
||||
"verror": "1.10.0"
|
||||
}
|
||||
},
|
||||
"keytar": {
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.7.0.tgz",
|
||||
"integrity": "sha512-YEY9HWqThQc5q5xbXbRwsZTh2PJ36OSYRjSv3NN2xf5s5dpLTjEZnC2YikR29OaVybf9nQ0dJ/80i40RS97t/A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"node-addon-api": "^3.0.0",
|
||||
"prebuild-install": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-addon-api": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.0.tgz",
|
||||
"integrity": "sha512-kcwSAWhPi4+QzAtsL2+2s/awvDo2GKLsvMCwNRxb5BUshteXU8U97NCyvQDsGKs/m0He9WcG4YWew/BnuLx++w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"jest": "^26.6.3",
|
||||
"sharp": "^0.26.2",
|
||||
"keytar": "^7.0.0",
|
||||
"typescript": "^4.0.5",
|
||||
"clean-html": "^1.5.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import KeychainService from '@joplin/lib/services/keychain/KeychainService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { db, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import KeychainService from '../../services/keychain/KeychainService';
|
||||
import shim from '../../shim';
|
||||
import Setting from '../../models/Setting';
|
||||
|
||||
const { db, setupDatabaseAndSynchronizer, switchClient } = require('../../testing/test-utils.js');
|
||||
|
||||
function describeIfCompatible(name: string, fn: any, elseFn: any) {
|
||||
if (['win32', 'darwin'].includes(shim.platformName())) {
|
||||
@@ -48,8 +48,10 @@ 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.
|
||||
*/
|
||||
|
||||
49
packages/lib/utils/credentialFiles.js
Normal file
49
packages/lib/utils/credentialFiles.js
Normal file
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.readCredentialFile = exports.credentialFile = exports.credentialDir = void 0;
|
||||
const fs = require('fs-extra');
|
||||
function credentialDir() {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const username = require('os').userInfo().username;
|
||||
const toTry = [
|
||||
`c:/Users/${username}/joplin-credentials`,
|
||||
`/mnt/c/Users/${username}/joplin-credentials`,
|
||||
`/home/${username}/joplin-credentials`,
|
||||
`/Users/${username}/joplin-credentials`,
|
||||
];
|
||||
for (const dirPath of toTry) {
|
||||
if (yield fs.pathExists(dirPath))
|
||||
return dirPath;
|
||||
}
|
||||
throw new Error(`Could not find credential directory in any of these paths: ${JSON.stringify(toTry)}`);
|
||||
});
|
||||
}
|
||||
exports.credentialDir = credentialDir;
|
||||
function credentialFile(filename) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const rootDir = yield credentialDir();
|
||||
const output = `${rootDir}/${filename}`;
|
||||
if (!(yield fs.pathExists(output)))
|
||||
throw new Error(`No such file: ${output}`);
|
||||
return output;
|
||||
});
|
||||
}
|
||||
exports.credentialFile = credentialFile;
|
||||
function readCredentialFile(filename) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
const filePath = yield credentialFile(filename);
|
||||
const r = yield fs.readFile(filePath);
|
||||
return r.toString();
|
||||
});
|
||||
}
|
||||
exports.readCredentialFile = readCredentialFile;
|
||||
//# sourceMappingURL=credentialFiles.js.map
|
||||
@@ -24,12 +24,8 @@ export async function credentialFile(filename: string) {
|
||||
return output;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
export async function readCredentialFile(filename: string) {
|
||||
const filePath = await credentialFile(filename);
|
||||
const r = await fs.readFile(filePath);
|
||||
return r.toString();
|
||||
}
|
||||
|
||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -6,5 +6,4 @@ db-*.sqlite
|
||||
*.pid
|
||||
logs/
|
||||
tests/temp/
|
||||
temp/
|
||||
.env
|
||||
temp/
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"watch": [
|
||||
"dist/",
|
||||
"../renderer",
|
||||
"../lib",
|
||||
"src/views"
|
||||
]
|
||||
"watch": ["dist/", "../renderer", "../lib"]
|
||||
}
|
||||
35
packages/server/package-lock.json
generated
35
packages/server/package-lock.json
generated
@@ -1389,15 +1389,6 @@
|
||||
"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",
|
||||
@@ -5967,19 +5958,6 @@
|
||||
"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",
|
||||
@@ -6067,14 +6045,6 @@
|
||||
"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",
|
||||
@@ -6178,11 +6148,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
"start-dev": "nodemon --config nodemon.json 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",
|
||||
@@ -23,7 +23,6 @@
|
||||
"dayjs": "^1.9.8",
|
||||
"formidable": "^1.2.2",
|
||||
"fs-extra": "^8.1.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"html-entities": "^1.3.1",
|
||||
"knex": "0.95.4",
|
||||
"koa": "^2.8.1",
|
||||
@@ -31,13 +30,11 @@
|
||||
"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": {
|
||||
@@ -49,7 +46,6 @@
|
||||
"@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,21 +67,10 @@ function markPasswords(o: Record<string, any>): Record<string, any> {
|
||||
return output;
|
||||
}
|
||||
|
||||
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 (argv.envFile) {
|
||||
nodeEnvFile(argv.envFile);
|
||||
}
|
||||
|
||||
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
|
||||
|
||||
@@ -102,8 +91,6 @@ async function main() {
|
||||
});
|
||||
Logger.initializeGlobalLogger(globalLogger);
|
||||
|
||||
if (envFilePath) appLogger().info(`Env variables were loaded from: ${envFilePath}`);
|
||||
|
||||
const pidFile = argv.pidfile as string;
|
||||
|
||||
if (pidFile) {
|
||||
@@ -142,7 +129,7 @@ async function main() {
|
||||
const appContext = app.context as AppContext;
|
||||
|
||||
await setupAppContext(appContext, env, connectionCheck.connection, appLogger);
|
||||
await initializeJoplinUtils(config(), appContext.models, appContext.services.mustache);
|
||||
await initializeJoplinUtils(config(), appContext.models);
|
||||
|
||||
appLogger().info('Migrating database...');
|
||||
await migrateDb(appContext.db);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig } from './utils/types';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient } from './utils/types';
|
||||
import * as pathUtils from 'path';
|
||||
|
||||
export interface EnvVariables {
|
||||
@@ -14,15 +14,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -66,19 +57,6 @@ 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);
|
||||
@@ -103,7 +81,6 @@ 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,11 +218,6 @@ export enum ItemType {
|
||||
User,
|
||||
}
|
||||
|
||||
export enum EmailSender {
|
||||
NoReply = 1,
|
||||
Support = 2,
|
||||
}
|
||||
|
||||
export enum ChangeType {
|
||||
Create = 1,
|
||||
Update = 2,
|
||||
@@ -282,8 +277,6 @@ 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 {
|
||||
@@ -377,25 +370,6 @@ 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' },
|
||||
@@ -407,8 +381,6 @@ 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' },
|
||||
@@ -513,26 +485,5 @@ 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)', ctx.models.user().profileUrl())
|
||||
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await 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 { isView, View } from '../services/MustacheService';
|
||||
// import config from '../config';
|
||||
import MustacheService, { 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 ctx.services.mustache.renderView(responseObject, {
|
||||
ctx.response.body = await 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 ctx.services.mustache.renderView(view);
|
||||
ctx.response.body = await mustache().renderView(view);
|
||||
} else { // JSON
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
const r: any = { error: error.message };
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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[] | number | number[], options: DeleteOptions = {}): Promise<void> {
|
||||
public async delete(id: string | string[], options: DeleteOptions = {}): Promise<void> {
|
||||
if (!id) throw new Error('id cannot be empty');
|
||||
|
||||
const ids = (typeof id === 'string' || typeof id === 'number') ? [id] : id;
|
||||
const ids = typeof id === 'string' ? [id] : id;
|
||||
|
||||
if (!ids.length) throw new Error('no id provided');
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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 { EmailSender, User } from '../db';
|
||||
import { User } from '../db';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
|
||||
describe('UserModel', function() {
|
||||
@@ -68,22 +68,4 @@ 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 { EmailSender, Item, User, Uuid } from '../db';
|
||||
import { Item, User } from '../db';
|
||||
import * as auth from '../utils/auth';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge } from '../utils/errors';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import prettyBytes = require('pretty-bytes');
|
||||
@@ -134,14 +134,10 @@ export default class UserModel extends BaseModel<User> {
|
||||
return !!s[0].length && !!s[1].length;
|
||||
}
|
||||
|
||||
public profileUrl(): string {
|
||||
public async profileUrl(): Promise<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);
|
||||
|
||||
@@ -155,13 +151,6 @@ 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:
|
||||
//
|
||||
@@ -171,30 +160,8 @@ 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);
|
||||
|
||||
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;
|
||||
});
|
||||
return super.save(user, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -63,11 +63,9 @@ 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 {
|
||||
|
||||
@@ -87,18 +85,10 @@ 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,9 +30,8 @@ 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 after clicking on the confirmation link.
|
||||
// set it by 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 } from '../../utils/views/table';
|
||||
import { makeTablePagination, Table, Row, makeTableView, tablePartials } from '../../utils/views/table';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -57,6 +57,7 @@ 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 } from '../../utils/views/table';
|
||||
import { makeTablePagination, makeTableView, Row, Table, tablePartials } from '../../utils/views/table';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
|
||||
@@ -67,6 +67,7 @@ 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.navbar = false;
|
||||
view.partials = ['errorBanner'];
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { User } from '../../db';
|
||||
import routeHandler from '../../middleware/routeHandler';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
|
||||
import { execRequest } 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,76 +153,6 @@ 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,26 +1,15 @@
|
||||
import { SubPath, redirect } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { bodyFields, formParse } from '../../utils/requestUtils';
|
||||
import { formParse } from '../../utils/requestUtils';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { NotificationLevel, User } from '../../db';
|
||||
import { 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 = {};
|
||||
|
||||
@@ -30,8 +19,10 @@ 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;
|
||||
|
||||
const password = checkPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
if (fields.password) {
|
||||
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
|
||||
user.password = fields.password;
|
||||
}
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
@@ -92,63 +83,11 @@ 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) => {
|
||||
|
||||
45
packages/server/src/services/BaseApplication.ts
Normal file
45
packages/server/src/services/BaseApplication.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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,9 +1,6 @@
|
||||
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;
|
||||
@@ -14,21 +11,12 @@ 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;
|
||||
@@ -39,27 +27,12 @@ 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_;
|
||||
}
|
||||
@@ -72,7 +45,7 @@ export default class MustacheService {
|
||||
return `${config().layoutDir}/default.mustache`;
|
||||
}
|
||||
|
||||
private get defaultLayoutOptions(): GlobalParams {
|
||||
private get defaultLayoutOptions(): any {
|
||||
return {
|
||||
baseUrl: config().baseUrl,
|
||||
prefersDarkEnabled: this.prefersDarkEnabled_,
|
||||
@@ -91,9 +64,17 @@ export default class MustacheService {
|
||||
return output;
|
||||
}
|
||||
|
||||
public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
|
||||
public async renderView(view: View, globalParams: any = null): Promise<string> {
|
||||
const partials = view.partials || [];
|
||||
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
|
||||
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
|
||||
|
||||
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 = {
|
||||
@@ -107,20 +88,19 @@ export default class MustacheService {
|
||||
...view.content,
|
||||
global: globalParams,
|
||||
},
|
||||
this.partials_
|
||||
partialContents
|
||||
);
|
||||
|
||||
const layoutView: any = {
|
||||
const layoutView: any = Object.assign({}, {
|
||||
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, this.partials_);
|
||||
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -24,7 +23,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(), config());
|
||||
const service = new ShareService(Env.Dev, models());
|
||||
void service.runInBackground();
|
||||
|
||||
await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F2', {
|
||||
|
||||
@@ -1,27 +1,73 @@
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import ChangeModel from '../models/ChangeModel';
|
||||
import BaseService from './BaseService';
|
||||
import { Models } from '../models/factory';
|
||||
import { Env } from '../utils/types';
|
||||
|
||||
const logger = Logger.create('ShareService');
|
||||
|
||||
export default class ShareService extends BaseService {
|
||||
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;
|
||||
|
||||
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 super.runInBackground();
|
||||
await this.maintenance();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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,8 +31,6 @@ const config = {
|
||||
'main.shares': 'WithDates, WithUuid',
|
||||
'main.share_users': 'WithDates, WithUuid',
|
||||
'main.user_items': 'WithDates',
|
||||
'main.emails': 'WithDates',
|
||||
'main.tokens': 'WithDates',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,8 +41,6 @@ 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,7 +9,6 @@ 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;
|
||||
|
||||
@@ -35,10 +34,6 @@ 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,6 +6,9 @@ export default function(name: string): View {
|
||||
name: name,
|
||||
path: `index/${name}`,
|
||||
content: {},
|
||||
navbar: true,
|
||||
partials: [
|
||||
'navbar',
|
||||
'notifications',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,10 +86,3 @@ 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,16 +55,15 @@ let baseUrl_: string = null;
|
||||
|
||||
export const resourceDirName = '.resource';
|
||||
|
||||
export async function initializeJoplinUtils(config: Config, models: Models, mustache: MustacheService) {
|
||||
export async function initializeJoplinUtils(config: Config, models: Models) {
|
||||
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_);
|
||||
@@ -79,8 +78,8 @@ export async function initializeJoplinUtils(config: Config, models: Models, must
|
||||
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[] {
|
||||
@@ -211,7 +210,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc
|
||||
};
|
||||
`,
|
||||
},
|
||||
}, { prefersDarkEnabled: false });
|
||||
});
|
||||
|
||||
return {
|
||||
body: bodyHtml,
|
||||
|
||||
@@ -39,20 +39,26 @@ export async function bodyFields<T>(req: any/* , filter:string[] = null*/): Prom
|
||||
// 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.
|
||||
|
||||
// console.info('FFFFFFFFFFF', req.headers);
|
||||
|
||||
// let previousContentType = null;
|
||||
// // if (req.headers['content-type'] !== 'application/json') {
|
||||
// if (!req.headers['content-type']) {
|
||||
// previousContentType = req.headers['content-type'];
|
||||
// req.headers['content-type'] = 'application/json';
|
||||
// }
|
||||
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;
|
||||
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.isPublic(match.subPath.schema) && !ctx.owner) throw new ErrorForbidden();
|
||||
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
|
||||
|
||||
return routeHandler(match.subPath, ctx);
|
||||
}
|
||||
|
||||
@@ -2,32 +2,22 @@ import { LoggerWrapper } from '@joplin/lib/Logger';
|
||||
import config from '../config';
|
||||
import { DbConnection } from '../db';
|
||||
import newModelFactory, { Models } from '../models/factory';
|
||||
import { AppContext, Config, Env } from './types';
|
||||
import { AppContext, 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';
|
||||
|
||||
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),
|
||||
function setupServices(env: Env, models: Models): Services {
|
||||
return {
|
||||
share: new ShareService(env, models),
|
||||
};
|
||||
|
||||
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 = await setupServices(env, appContext.models, config());
|
||||
appContext.services = setupServices(env, appContext.models);
|
||||
appContext.appLogger = appLogger;
|
||||
appContext.routes = { ...routes };
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { AppContext } from './types';
|
||||
|
||||
export default async function startServices(appContext: AppContext) {
|
||||
const services = appContext.services;
|
||||
|
||||
void services.share.runInBackground();
|
||||
void services.email.runInBackground();
|
||||
void services.cron.runInBackground();
|
||||
export default function startServices(appContext: AppContext) {
|
||||
void appContext.services.share.runInBackground();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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.
|
||||
@@ -85,10 +84,7 @@ export async function beforeAllDb(unitName: string) {
|
||||
await createDb(config().database, { dropIfExists: true });
|
||||
db_ = await connectDb(config().database);
|
||||
|
||||
const mustache = new MustacheService(config().viewDir, config().baseUrl);
|
||||
await mustache.loadPartials();
|
||||
|
||||
await initializeJoplinUtils(config(), models(), mustache);
|
||||
await initializeJoplinUtils(config(), models());
|
||||
}
|
||||
|
||||
export async function afterAllTests() {
|
||||
|
||||
@@ -44,29 +44,17 @@ 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;
|
||||
// Note that, for now, nothing is being logged to file. Log is just printed
|
||||
// Not 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;
|
||||
baseUrl: string;
|
||||
database: DatabaseConfig;
|
||||
mailer: MailerConfig;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
{{/stack}}
|
||||
</div>
|
||||
{{#owner}}
|
||||
<p><a href="{{{global.baseUrl}}}/home">Go to home page</a></p>
|
||||
<p><a href="{{{global.baseUrl}}}/home">Back to home page</a></p>
|
||||
{{/owner}}
|
||||
{{^owner}}
|
||||
<p><a href="{{{global.baseUrl}}}/login">Go to login page</a></p>
|
||||
<p><a href="{{{global.baseUrl}}}/login">Back to login page</a></p>
|
||||
{{/owner}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<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>{{message}}</strong>
|
||||
{{#stack}}
|
||||
<strong>{{error.message}}</strong>
|
||||
{{#error.stack}}
|
||||
<pre>{{.}}</pre>
|
||||
{{/stack}}
|
||||
{{/error.stack}}
|
||||
</div>
|
||||
{{/error}}
|
||||
@@ -1,28 +1,26 @@
|
||||
{{#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>
|
||||
<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>
|
||||
</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>
|
||||
</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 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>
|
||||
</nav>
|
||||
{{/navbar}}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -6,13 +6,13 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: 南宫小骏 <jackytsu@vip.qq.com>\n"
|
||||
"Language-Team: zh_CN <jackytsu.vip.qq.com>\n"
|
||||
"Last-Translator: Yang Zhang <zyangmath@gmail.com>\n"
|
||||
"Language-Team: zh_CN <yaozeye@yaozeye.onmicrosoft.com>\n"
|
||||
"Language: zh_CN\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.4.3\n"
|
||||
"X-Generator: Poedit 2.4.2\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
|
||||
@@ -119,11 +119,11 @@ msgstr "下载"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Skip this version"
|
||||
msgstr "跳过该版本"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Full changelog"
|
||||
msgstr "完整更新记录"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
|
||||
#, javascript-format
|
||||
@@ -267,10 +267,12 @@ msgid "Retry"
|
||||
msgstr "重试"
|
||||
|
||||
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:113
|
||||
#, fuzzy
|
||||
msgid "Advanced tools"
|
||||
msgstr "高级工具"
|
||||
msgstr "高级选项"
|
||||
|
||||
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:115
|
||||
#, fuzzy
|
||||
msgid "Export debug report"
|
||||
msgstr "导出调试报告"
|
||||
|
||||
@@ -328,23 +330,23 @@ msgstr "复选框列表"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17
|
||||
msgid "Highlight"
|
||||
msgstr "突出显示"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22
|
||||
msgid "Strikethrough"
|
||||
msgstr "删除线"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27
|
||||
msgid "Insert"
|
||||
msgstr "插入"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33
|
||||
msgid "Superscript"
|
||||
msgstr "上标"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39
|
||||
msgid "Subscript"
|
||||
msgstr "订阅"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:544
|
||||
#: packages/app-mobile/components/screens/Note.js:1016
|
||||
@@ -505,8 +507,9 @@ msgid "Delete line"
|
||||
msgstr "删除行"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92
|
||||
#, fuzzy
|
||||
msgid "Duplicate line"
|
||||
msgstr "复制行"
|
||||
msgstr "创建副本"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96
|
||||
msgid "Undo"
|
||||
@@ -697,11 +700,11 @@ msgstr "保存"
|
||||
msgid ""
|
||||
"Safe mode is currently active. Note rendering and all plugins are "
|
||||
"temporarily disabled."
|
||||
msgstr "安全模式当前已被激活。笔记渲染和所有的插件被临时禁用。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:438
|
||||
msgid "Disable safe mode and restart"
|
||||
msgstr "禁用安全模式并重启"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:442
|
||||
msgid ""
|
||||
@@ -743,15 +746,15 @@ msgstr "更多信息"
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:468
|
||||
#, javascript-format
|
||||
msgid "%s (%s) would like to share a notebook with you."
|
||||
msgstr "%s (%s) 想要分享笔记本给你。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:470
|
||||
msgid "Accept"
|
||||
msgstr "接受"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:472
|
||||
msgid "Reject"
|
||||
msgstr "拒绝"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:476
|
||||
msgid "Some items cannot be synchronised."
|
||||
@@ -791,11 +794,11 @@ msgstr "新建待办事项"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js:18
|
||||
msgid "Toggle note list"
|
||||
msgstr "切换笔记列表"
|
||||
msgstr "显示/隐藏笔记列表"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js:18
|
||||
msgid "Toggle sidebar"
|
||||
msgstr "切换边栏"
|
||||
msgstr "显示/隐藏边栏"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/editAlarm.js:20
|
||||
#: packages/app-mobile/components/SelectDateTimeDialog.js:84
|
||||
@@ -835,8 +838,9 @@ msgid "Toggle editors"
|
||||
msgstr "切换编辑器"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
|
||||
#, fuzzy
|
||||
msgid "Share notebook..."
|
||||
msgstr "分享笔记本..."
|
||||
msgstr "分享笔记..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
|
||||
msgid "Change application layout"
|
||||
@@ -913,7 +917,7 @@ msgstr "令牌已复制到剪贴板!"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:44
|
||||
msgid "Are you sure you want to renew the authorisation token?"
|
||||
msgstr "确定要更新授权令牌吗?"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
|
||||
msgid "The web clipper service is enabled and set to auto-start."
|
||||
@@ -992,7 +996,7 @@ msgstr "该授权令牌仅用于允许第三方应用程序访问 Joplin。"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:222
|
||||
msgid "Renew token"
|
||||
msgstr "更新令牌"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:167
|
||||
#, javascript-format
|
||||
@@ -1171,7 +1175,7 @@ msgstr "以下笔记已被导入: %s"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.js:44
|
||||
msgid "Duplicate"
|
||||
msgstr "复制"
|
||||
msgstr "创建副本"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.js:49
|
||||
#, javascript-format
|
||||
@@ -1464,12 +1468,13 @@ msgid "You do not have any installed plugin."
|
||||
msgstr "您尚未安装任何插件。"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
|
||||
#, fuzzy
|
||||
msgid "Could not connect to plugin repository"
|
||||
msgstr "无法连接到插件库"
|
||||
msgstr "无法安装插件:%s"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
|
||||
msgid "Try again"
|
||||
msgstr "重试"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
|
||||
msgid "Plugin tools"
|
||||
@@ -1679,18 +1684,19 @@ msgid_plural "Copy Shareable Links"
|
||||
msgstr[0] "复制分享链接"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
|
||||
#, fuzzy
|
||||
msgid "Unshare"
|
||||
msgstr "取消分享"
|
||||
msgstr "分享"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
|
||||
msgid ""
|
||||
"Delete this invitation? The recipient will no longer have access to this "
|
||||
"shared notebook."
|
||||
msgstr "删除这个邀请?接受者将无法再访问到这个共享的笔记本。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
|
||||
msgid "Add recipient:"
|
||||
msgstr "添加接受者:"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
|
||||
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
|
||||
@@ -1700,41 +1706,45 @@ msgstr "分享"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
|
||||
msgid "Recipient has not yet accepted the invitation"
|
||||
msgstr "接受者还没有接受邀请"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
|
||||
msgid "Recipient has rejected the invitation"
|
||||
msgstr "接受者拒绝了邀请"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
|
||||
msgid "Recipient has accepted the invitation"
|
||||
msgstr "接受者接受了邀请"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
|
||||
msgid "Recipients:"
|
||||
msgstr "接受者:"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
|
||||
#, fuzzy
|
||||
msgid "Synchronizing..."
|
||||
msgstr "正在同步..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
|
||||
#, fuzzy
|
||||
msgid "Sharing notebook..."
|
||||
msgstr "分享笔记本..."
|
||||
msgstr "分享笔记..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
|
||||
msgid ""
|
||||
"Unshare this notebook? The recipients will no longer have access to its "
|
||||
"content."
|
||||
msgstr "取消分享这个笔记本?接受者将无法再访问到它的内容。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
|
||||
#, fuzzy
|
||||
msgid "Share Notebook"
|
||||
msgstr "分享笔记本"
|
||||
msgstr "分享笔记"
|
||||
|
||||
#: packages/app-desktop/commands/toggleSafeMode.js:18
|
||||
#, fuzzy
|
||||
msgid "Toggle safe mode"
|
||||
msgstr "切换安全模式"
|
||||
msgstr "显示/隐藏边栏"
|
||||
|
||||
#: packages/app-desktop/commands/toggleExternalEditing.js:18
|
||||
msgid "Toggle external editing"
|
||||
@@ -1826,7 +1836,7 @@ msgstr "配置"
|
||||
|
||||
#: packages/app-mobile/components/side-menu-content.js:351
|
||||
msgid "Mobile data - auto-sync disabled"
|
||||
msgstr "手机数据自动同步被禁用"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:97
|
||||
msgid "You currently have no notebooks."
|
||||
@@ -2339,8 +2349,9 @@ msgid "Joplin Server URL"
|
||||
msgstr "Joplin 服务器 URL"
|
||||
|
||||
#: packages/lib/models/Setting.js:335
|
||||
#, fuzzy
|
||||
msgid "Joplin Server email"
|
||||
msgstr "Joplin Server 邮箱"
|
||||
msgstr "Joplin 服务器"
|
||||
|
||||
#: packages/lib/models/Setting.js:346
|
||||
msgid "Joplin Server password"
|
||||
@@ -2582,11 +2593,11 @@ msgid ""
|
||||
"Used for most text in the markdown editor. If not found, a generic "
|
||||
"proportional (variable width) font is used."
|
||||
msgstr ""
|
||||
"用于 markdown 编辑器中的大多数文本。如果没找到,会使用默认(非等宽)字体。"
|
||||
|
||||
#: packages/lib/models/Setting.js:726
|
||||
#, fuzzy
|
||||
msgid "Editor monospace font family"
|
||||
msgstr "编辑器等宽字体族"
|
||||
msgstr "编辑器字体族"
|
||||
|
||||
#: packages/lib/models/Setting.js:727
|
||||
msgid ""
|
||||
@@ -2594,8 +2605,6 @@ msgid ""
|
||||
"tables, checkboxes, code). If not found, a generic monospace (fixed width) "
|
||||
"font is used."
|
||||
msgstr ""
|
||||
"用于那些需要使用固定宽度的字体来清晰地布局文本的场景(例如:表格、多选框和代"
|
||||
"码块)。如果没有找到,会使用默认(等宽)字体。"
|
||||
|
||||
#: packages/lib/models/Setting.js:748
|
||||
msgid "Custom stylesheet for rendered Markdown"
|
||||
@@ -2648,7 +2657,7 @@ msgstr "%d 小时"
|
||||
|
||||
#: packages/lib/models/Setting.js:817
|
||||
msgid "Synchronise only over WiFi connection"
|
||||
msgstr "只通过 WiFi 网络同步数据"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.js:824
|
||||
msgid "Text editor command"
|
||||
@@ -3725,7 +3734,7 @@ msgstr "将选定文件添加到笔记中。"
|
||||
msgid ""
|
||||
"Runs the commands contained in the text file. There should be one command "
|
||||
"per line."
|
||||
msgstr "执行文本文件中包含的命令。每个命令占一行。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-cli/app/command-version.js:11
|
||||
msgid "Displays version information"
|
||||
|
||||
Reference in New Issue
Block a user