1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-30 10:36:35 +02:00

Server: Add Stripe integration

This commit is contained in:
Laurent Cozic 2021-06-03 15:21:02 +02:00
parent c88e4f6628
commit 770af6a53b
28 changed files with 786 additions and 73 deletions

View File

@ -901,6 +901,14 @@
}
}
},
"@koa/cors": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-3.1.0.tgz",
"integrity": "sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==",
"requires": {
"vary": "^1.1.2"
}
},
"@rmp135/sql-ts": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@rmp135/sql-ts/-/sql-ts-1.7.0.tgz",
@ -1386,8 +1394,7 @@
"@types/node": {
"version": "12.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz",
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==",
"dev": true
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w=="
},
"@types/nodemailer": {
"version": "6.4.1",
@ -2034,6 +2041,11 @@
"resolved": "https://registry.npmjs.org/bulma-prefers-dark/-/bulma-prefers-dark-0.1.0-beta.0.tgz",
"integrity": "sha512-EeDW8pQrkYEOXo2l3WykfghbUzi8jlQWGI+Cu2HwmXwQHMcoGF6yiKYCNShttN+8z3atq8fLWh3B7pqXUV4fBA=="
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
},
"cache-base": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
@ -2098,6 +2110,15 @@
}
}
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
}
},
"callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -3222,8 +3243,7 @@
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"gauge": {
"version": "2.7.4",
@ -3246,6 +3266,16 @@
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true
},
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1"
}
},
"get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@ -3410,7 +3440,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
@ -3420,6 +3449,11 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"has-symbols": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
},
"has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@ -6335,6 +6369,11 @@
}
}
},
"object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
},
"object-visit": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
@ -6869,6 +6908,44 @@
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true
},
"raw-body": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
"integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==",
"requires": {
"bytes": "3.1.0",
"http-errors": "1.7.3",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"dependencies": {
"http-errors": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
"requires": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
}
}
},
"rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@ -7261,6 +7338,16 @@
"dev": true,
"optional": true
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"requires": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
}
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
@ -7647,6 +7734,25 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
},
"stripe": {
"version": "8.150.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.150.0.tgz",
"integrity": "sha512-48YMLupzvDyVZUs37xUBd1SF0E3B77ahOTLhL7ycVwZqwjlQ30K7iHTejIAUdtEnWaNkaOz0LX6jHeR49IulRQ==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.6.0"
},
"dependencies": {
"qs": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
"integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
"requires": {
"side-channel": "^1.0.4"
}
}
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -7956,6 +8062,11 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
},
"unset-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",

View File

@ -17,6 +17,7 @@
"@fortawesome/fontawesome-free": "^5.15.1",
"@joplin/lib": "^1.0.9",
"@joplin/renderer": "^1.7.4",
"@koa/cors": "^3.1.0",
"bcryptjs": "^2.4.3",
"bulma": "^0.9.1",
"bulma-prefers-dark": "^0.1.0-beta.0",
@ -29,14 +30,16 @@
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",
"nanoid": "^2.1.1",
"node-cron": "^3.0.0",
"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",
"raw-body": "^2.4.1",
"sqlite3": "^4.1.0",
"node-cron": "^3.0.0",
"stripe": "^8.150.0",
"yargs": "^14.0.0"
},
"devDependencies": {

Binary file not shown.

View File

@ -16,7 +16,9 @@ import ownerHandler from './middleware/ownerHandler';
import setupAppContext from './utils/setupAppContext';
import { initializeJoplinUtils } from './utils/joplinUtils';
import startServices from './utils/startServices';
import { credentialFile } from './utils/testing/testUtils';
const cors = require('@koa/cors');
const nodeEnvFile = require('node-env-file');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
shimInit();
@ -50,6 +52,18 @@ const app = new Koa();
// loads the user, which is then used by notificationHandler. And finally
// routeHandler uses data from both previous middlewares. It would be good to
// layout these dependencies in code but not clear how to do this.
const corsAllowedDomains = ['https://joplinapp.org'];
app.use(cors({
// https://github.com/koajs/cors/issues/52#issuecomment-413887382
origin: (ctx: AppContext) => {
if (corsAllowedDomains.indexOf(ctx.request.header.origin) !== -1) {
return ctx.request.header.origin;
}
// we can't return void, so let's return one of the valid domains
return corsAllowedDomains[0];
},
}));
app.use(ownerHandler);
app.use(notificationHandler);
app.use(routeHandler);
@ -72,8 +86,7 @@ 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 credentialFile('server.env');
}
return '';
@ -86,7 +99,7 @@ async function main() {
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
await initConfig({
await initConfig(env, {
...envVariables[env],
...process.env,
});

View File

@ -1,9 +1,11 @@
import { rtrimSlashes } from '@joplin/lib/path-utils';
import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig, RouteType } from './utils/types';
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteType, StripeConfig } from './utils/types';
import * as pathUtils from 'path';
import { readFile } from 'fs-extra';
export interface EnvVariables {
APP_NAME?: string;
APP_BASE_URL?: string;
USER_CONTENT_BASE_URL?: string;
API_BASE_URL?: string;
@ -29,6 +31,10 @@ export interface EnvVariables {
// This must be the full path to the database file
SQLITE_DATABASE?: string;
STRIPE_SECRET_KEY?: string;
STRIPE_PUBLISHABLE_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
}
let runningInDocker_: boolean = false;
@ -84,6 +90,14 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
};
}
function stripeConfigFromEnv(env: EnvVariables): StripeConfig {
return {
secretKey: env.STRIPE_SECRET_KEY || '',
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
};
}
function baseUrlFromEnv(env: any, appPort: number): string {
if (env.APP_BASE_URL) {
return rtrimSlashes(env.APP_BASE_URL);
@ -103,7 +117,7 @@ async function readPackageJson(filePath: string): Promise<PackageJson> {
let config_: Config = null;
export async function initConfig(env: EnvVariables, overrides: any = null) {
export async function initConfig(envType: Env, env: EnvVariables, overrides: any = null) {
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
const rootDir = pathUtils.dirname(__dirname);
@ -116,7 +130,8 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
config_ = {
appVersion: packageJson.version,
appName: 'Joplin Server',
appName: env.APP_NAME || 'Joplin Server',
env: envType,
rootDir: rootDir,
viewDir: viewDir,
layoutDir: `${viewDir}/layouts`,
@ -124,6 +139,7 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
logDir: `${rootDir}/logs`,
database: databaseConfigFromEnv(runningInDocker_, env),
mailer: mailerConfigFromEnv(env),
stripe: stripeConfigFromEnv(env),
port: appPort,
baseUrl,
apiBaseUrl: env.API_BASE_URL ? env.API_BASE_URL : baseUrl,

View File

@ -394,6 +394,17 @@ export interface Token extends WithDates {
user_id?: Uuid;
}
export interface Subscription {
id?: number;
user_id?: Uuid;
stripe_user_id?: Uuid;
stripe_subscription_id?: Uuid;
last_payment_time?: number;
last_payment_failed_time?: number;
updated_time?: string;
created_time?: string;
}
export const databaseSchema: DatabaseTables = {
users: {
id: { type: 'string' },
@ -534,5 +545,15 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
subscriptions: {
id: { type: 'number' },
user_id: { type: 'string' },
stripe_user_id: { type: 'string' },
stripe_subscription_id: { type: 'string' },
last_payment_time: { type: 'string' },
last_payment_failed_time: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@ -0,0 +1,19 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('subscriptions', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('user_id', 32).notNullable();
table.string('stripe_user_id', 64).notNullable();
table.string('stripe_subscription_id', 64).notNullable();
table.bigInteger('last_payment_time').notNullable();
table.bigInteger('last_payment_failed_time').defaultTo(0).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@ -4,6 +4,7 @@ import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
export interface SaveOptions {
isNew?: boolean;
@ -41,13 +42,13 @@ export default abstract class BaseModel<T> {
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
private baseUrl_: string;
private static eventEmitter_: EventEmitter = null;
private config_: Config;
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
this.db_ = db;
this.modelFactory_ = modelFactory;
this.baseUrl_ = baseUrl;
this.config_ = config;
this.transactionHandler_ = new TransactionHandler(db);
}
@ -56,11 +57,15 @@ export default abstract class BaseModel<T> {
// connection is passed to it. That connection can be the regular db
// connection, or the active transaction.
protected models(db: DbConnection = null): Models {
return this.modelFactory_(db || this.db);
return this.modelFactory_(db || this.db, this.config_);
}
protected get baseUrl(): string {
return this.baseUrl_;
return this.config_.baseUrl;
}
protected get appName(): string {
return this.config_.appName;
}
protected get db(): DbConnection {

View File

@ -15,25 +15,6 @@ interface NotificationType {
message: string;
}
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Server! An email has been sent to you containing an activation link to complete your registration.',
},
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'You email has been confirmed',
},
[NotificationKey.PasswordSet]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Server! Your password has been set successfully.',
},
[NotificationKey.UsingSqliteInProd]: {
level: NotificationLevel.Important,
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
},
};
export default class NotificationModel extends BaseModel<Notification> {
protected get tableName(): string {
@ -49,6 +30,25 @@ export default class NotificationModel extends BaseModel<Notification> {
const n: Notification = await this.loadByKey(userId, key);
if (n) return n;
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
},
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'Your email has been confirmed',
},
[NotificationKey.PasswordSet]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! Your password has been set successfully.`,
},
[NotificationKey.UsingSqliteInProd]: {
level: NotificationLevel.Important,
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
},
};
const type = notificationTypes[key];
if (level === null) {

View File

@ -0,0 +1,40 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
import { AccountType } from './UserModel';
import { MB } from '../utils/bytes';
describe('SubscriptionModel', function() {
beforeAll(async () => {
await beforeAllDb('SubscriptionModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a user and subscription', async function() {
await models().subscription().saveUserAndSubscription(
'toto@example.com',
AccountType.Pro,
'STRIPE_USER_ID',
'STRIPE_SUB_ID'
);
const user = await models().user().loadByEmail('toto@example.com');
const sub = await models().subscription().byStripeSubscriptionId('STRIPE_SUB_ID');
expect(user.account_type).toBe(AccountType.Pro);
expect(user.email).toBe('toto@example.com');
expect(user.can_share).toBe(1);
expect(user.max_item_size).toBe(200 * MB);
expect(sub.stripe_subscription_id).toBe('STRIPE_SUB_ID');
expect(sub.stripe_user_id).toBe('STRIPE_USER_ID');
expect(sub.user_id).toBe(user.id);
});
});

View File

@ -0,0 +1,72 @@
import { EmailSender, Subscription, Uuid } from '../db';
import { ErrorNotFound } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import BaseModel from './BaseModel';
import { AccountType, accountTypeProperties } from './UserModel';
export default class SubscriptionModel extends BaseModel<Subscription> {
public get tableName(): string {
return 'subscriptions';
}
protected hasUuid(): boolean {
return false;
}
public async handlePayment(subscriptionId: string, success: boolean) {
const sub = await this.byStripeSubscriptionId(subscriptionId);
if (!sub) throw new ErrorNotFound(`No such subscription: ${subscriptionId}`);
const now = Date.now();
const toSave: Subscription = { id: sub.id };
if (success) {
toSave.last_payment_time = now;
} else {
toSave.last_payment_failed_time = now;
const user = await this.models().user().load(sub.user_id, { fields: ['email'] });
await this.models().email().push({
subject: `${this.appName} subscription payment failed`,
body: `Your invoice payment has failed. Please follow this URL to update your payment details: \n\n[Manage your subscription](${this.baseUrl}/portal)`,
recipient_email: user.email,
sender_id: EmailSender.Support,
});
}
await this.save(toSave);
}
public async byStripeSubscriptionId(id: string): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).first();
}
public async byUserId(userId: Uuid): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).first();
}
public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
return this.withTransaction(async () => {
const user = await this.models().user().save({
...accountTypeProperties(accountType),
email,
email_confirmed: 1,
password: uuidgen(),
must_set_password: 1,
});
const subscription = await this.save({
user_id: user.id,
stripe_user_id: stripeUserId,
stripe_subscription_id: stripeSubscriptionId,
last_payment_time: Date.now(),
});
return { user, subscription };
});
}
}

View File

@ -4,7 +4,41 @@ import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import prettyBytes = require('pretty-bytes');
import { formatBytes, MB } from '../utils/bytes';
export enum AccountType {
Default = 0,
Free = 1,
Pro = 2,
}
interface AccountTypeProperties {
account_type: number;
can_share: number;
max_item_size: number;
}
export function accountTypeProperties(accountType: AccountType): AccountTypeProperties {
const types: AccountTypeProperties[] = [
{
account_type: AccountType.Default,
can_share: 1,
max_item_size: 0,
},
{
account_type: AccountType.Free,
can_share: 0,
max_item_size: 10 * MB,
},
{
account_type: AccountType.Pro,
can_share: 1,
max_item_size: 200 * MB,
},
];
return types.find(a => a.account_type === accountType);
}
export default class UserModel extends BaseModel<User> {
@ -85,7 +119,7 @@ export default class UserModel extends BaseModel<User> {
throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)',
isNote ? _('note') : _('attachment'),
itemTitle ? itemTitle : name,
prettyBytes(user.max_item_size)
formatBytes(user.max_item_size)
));
}
}
@ -188,11 +222,13 @@ export default class UserModel extends BaseModel<User> {
recipient_id: savedUser.id,
recipient_email: savedUser.email,
recipient_name: savedUser.full_name || '',
subject: 'Please setup your Joplin account',
body: `Your new Joplin account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n${confirmUrl}`,
subject: `Please setup your ${this.appName} account`,
body: `Your new ${this.appName} account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n[Complete your account](${confirmUrl})`,
});
}
UserModel.eventEmitter.emit('created');
return savedUser;
});
}

View File

@ -68,71 +68,77 @@ import ItemResourceModel from './ItemResourceModel';
import ShareUserModel from './ShareUserModel';
import KeyValueModel from './KeyValueModel';
import TokenModel from './TokenModel';
import SubscriptionModel from './SubscriptionModel';
import { Config } from '../utils/types';
export class Models {
private db_: DbConnection;
private baseUrl_: string;
private config_: Config;
public constructor(db: DbConnection, baseUrl: string) {
public constructor(db: DbConnection, config: Config) {
this.db_ = db;
this.baseUrl_ = baseUrl;
this.config_ = config;
}
public item() {
return new ItemModel(this.db_, newModelFactory, this.baseUrl_);
return new ItemModel(this.db_, newModelFactory, this.config_);
}
public user() {
return new UserModel(this.db_, newModelFactory, this.baseUrl_);
return new UserModel(this.db_, newModelFactory, this.config_);
}
public email() {
return new EmailModel(this.db_, newModelFactory, this.baseUrl_);
return new EmailModel(this.db_, newModelFactory, this.config_);
}
public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
return new UserItemModel(this.db_, newModelFactory, this.config_);
}
public token() {
return new TokenModel(this.db_, newModelFactory, this.baseUrl_);
return new TokenModel(this.db_, newModelFactory, this.config_);
}
public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
return new ItemResourceModel(this.db_, newModelFactory, this.config_);
}
public apiClient() {
return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_);
return new ApiClientModel(this.db_, newModelFactory, this.config_);
}
public session() {
return new SessionModel(this.db_, newModelFactory, this.baseUrl_);
return new SessionModel(this.db_, newModelFactory, this.config_);
}
public change() {
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_);
return new ChangeModel(this.db_, newModelFactory, this.config_);
}
public notification() {
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_);
return new NotificationModel(this.db_, newModelFactory, this.config_);
}
public share() {
return new ShareModel(this.db_, newModelFactory, this.baseUrl_);
return new ShareModel(this.db_, newModelFactory, this.config_);
}
public shareUser() {
return new ShareUserModel(this.db_, newModelFactory, this.baseUrl_);
return new ShareUserModel(this.db_, newModelFactory, this.config_);
}
public keyValue() {
return new KeyValueModel(this.db_, newModelFactory, this.baseUrl_);
return new KeyValueModel(this.db_, newModelFactory, this.config_);
}
public subscription() {
return new SubscriptionModel(this.db_, newModelFactory, this.config_);
}
}
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
return new Models(db, baseUrl);
export default function newModelFactory(db: DbConnection, config: Config): Models {
return new Models(db, config);
}

View File

@ -1,3 +1,4 @@
import config from '../../config';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
@ -6,7 +7,7 @@ const router = new Router(RouteType.Api);
router.public = true;
router.get('api/ping', async () => {
return { status: 'ok', message: 'Joplin Server is running' };
return { status: 'ok', message: `${config().appName} is running` };
});
export default router;

View File

@ -10,7 +10,7 @@ import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
const prettyBytes = require('pretty-bytes');
import { formatBytes } from '../../utils/bytes';
const router = new Router(RouteType.Web);
@ -50,7 +50,7 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
url: showItemUrls(config()) ? `${config().userContentBaseUrl}/items/${item.id}/content` : null,
},
{
value: prettyBytes(item.content_size),
value: formatBytes(item.content_size),
},
{
value: item.mime_type || 'binary',

View File

@ -1,4 +1,6 @@
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { MB } from '../../utils/bytes';
import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
@ -27,7 +29,10 @@ describe('index_signup', function() {
// Check that the user has been created
const user = await models().user().loadByEmail('toto@example.com');
expect(user).toBeTruthy();
expect(user.account_type).toBe(AccountType.Free);
expect(user.email_confirmed).toBe(0);
expect(user.can_share).toBe(0);
expect(user.max_item_size).toBe(10 * MB);
// Check that the user is logged in
const session = await models().session().load(context.cookies.get('sessionId'));

View File

@ -8,6 +8,7 @@ import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { checkPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType, accountTypeProperties } from '../../models/UserModel';
function makeView(error: Error = null): View {
const view = defaultView('signup');
@ -38,6 +39,7 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
const password = checkPassword(formUser, true);
const user = await ctx.models.user().save({
...accountTypeProperties(AccountType.Free),
email: formUser.email,
full_name: formUser.full_name,
password,

View File

@ -0,0 +1,289 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType, StripeConfig } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils';
import globalConfig from '../../config';
import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
const stripeLib = require('stripe');
const logger = Logger.create('/stripe');
const router: Router = new Router(RouteType.Web);
router.public = true;
function stripeConfig(): StripeConfig {
return globalConfig().stripe;
}
function initStripe(): Stripe {
return stripeLib(stripeConfig().secretKey);
}
async function stripeEvent(stripe: Stripe, req: any): Promise<Stripe.Event> {
if (!stripeConfig().webhookSecret) throw new Error('webhookSecret is required');
const body = await getRawBody(req);
return stripe.webhooks.constructEvent(
body,
req.headers['stripe-signature'],
stripeConfig().webhookSecret
);
}
interface CreateCheckoutSessionFields {
priceId: string;
}
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise<any>;
const postHandlers: Record<string, StripeRouteHandler> = {
createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
const fields = await bodyFields<CreateCheckoutSessionFields>(ctx.req);
const priceId = fields.priceId;
// See https://stripe.com/docs/api/checkout/sessions/create
// for additional parameters to pass.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
// For metered billing, do not pass quantity
quantity: 1,
},
],
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
// the actual Session ID is returned in the query parameter when your customer
// is redirected to the success page.
success_url: `${globalConfig().baseUrl}/stripe/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${globalConfig().baseUrl}/stripe/cancel`,
});
return {
sessionId: session.id,
};
},
webhook: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
const event = await stripeEvent(stripe, ctx.req);
const hooks: any = {
'checkout.session.completed': async () => {
// Payment is successful and the subscription is created.
//
// For testing: `stripe trigger checkout.session.completed`
// Or use /checkoutTest URL.
// {
// "object": {
// "id": "cs_test_xxxxxxxxxxxxxxxxxx",
// "object": "checkout.session",
// "allow_promotion_codes": null,
// "amount_subtotal": 499,
// "amount_total": 499,
// "billing_address_collection": null,
// "cancel_url": "http://joplincloud.local:22300/stripe/cancel",
// "client_reference_id": null,
// "currency": "gbp",
// "customer": "cus_xxxxxxxxxxxx",
// "customer_details": {
// "email": "toto@example.com",
// "tax_exempt": "none",
// "tax_ids": [
// ]
// },
// "customer_email": null,
// "livemode": false,
// "locale": null,
// "metadata": {
// },
// "mode": "subscription",
// "payment_intent": null,
// "payment_method_options": {
// },
// "payment_method_types": [
// "card"
// ],
// "payment_status": "paid",
// "setup_intent": null,
// "shipping": null,
// "shipping_address_collection": null,
// "submit_type": null,
// "subscription": "sub_xxxxxxxxxxxxxxxx",
// "success_url": "http://joplincloud.local:22300/stripe/success?session_id={CHECKOUT_SESSION_ID}",
// "total_details": {
// "amount_discount": 0,
// "amount_shipping": 0,
// "amount_tax": 0
// }
// }
// }
const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
// The Stripe TypeScript object defines "customer" and
// "subscription" as various types but they are actually
// string according to the documentation.
const stripeUserId = checkoutSession.customer as string;
const stripeSubscriptionId = checkoutSession.subscription as string;
await ctx.models.subscription().saveUserAndSubscription(
checkoutSession.customer_details.email || checkoutSession.customer_email,
AccountType.Pro,
stripeUserId,
stripeSubscriptionId
);
},
'invoice.paid': async () => {
// Continue to provision the subscription as payments continue
// to be made. Store the status in your database and check when
// a user accesses your service. This approach helps you avoid
// hitting rate limits.
//
// Note that when the subscription is created, this event is
// going to be triggered before "checkout.session.completed" (at
// least in tests), which means it won't find the subscription
// at this point, but this is fine because the required data is
// saved in checkout.session.completed.
const invoice = event.data.object as Stripe.Invoice;
await ctx.models.subscription().handlePayment(invoice.subscription as string, true);
},
'invoice.payment_failed': async () => {
// The payment failed or the customer does not have a valid payment method.
// The subscription becomes past_due. Notify your customer and send them to the
// customer portal to update their payment information.
//
// For testing: `stripe trigger invoice.payment_failed`
const invoice = event.data.object as Stripe.Invoice;
const subId = invoice.subscription as string;
await ctx.models.subscription().handlePayment(subId, false);
},
};
if (hooks[event.type]) {
logger.info(`Got Stripe event: ${event.type} [Handled]`);
await hooks[event.type]();
} else {
logger.info(`Got Stripe event: ${event.type} [Unhandled]`);
}
},
};
const getHandlers: Record<string, StripeRouteHandler> = {
success: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<p>Thank you for signing up for ${globalConfig().appName} Pro! You should receive an email shortly with instructions on how to connect to your account.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
},
cancel: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<p>Your payment has been cancelled.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
},
portal: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
if (!ctx.owner) throw new ErrorForbidden('Please login to access the subscription portal');
const sub = await ctx.models.subscription().byUserId(ctx.owner.id);
if (!sub) throw new ErrorNotFound('Could not find subscription');
const billingPortalSession = await stripe.billingPortal.sessions.create({ customer: sub.stripe_user_id as string });
return `
<html>
<head>
<meta http-equiv = "refresh" content = "1; url = ${billingPortalSession.url};" />
<script>setTimeout(() => { window.location.href = ${JSON.stringify(billingPortalSession.url)}; }, 2000)</script>
</head>
<body>
Redirecting to subscription portal...
</body>
</html>`;
},
checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<head>
<title>Checkout</title>
<script src="https://js.stripe.com/v3/"></script>
<script>
var stripe = Stripe(${JSON.stringify(stripeConfig().publishableKey)});
var createCheckoutSession = function(priceId) {
return fetch("/stripe/createCheckoutSession", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
priceId: priceId
})
}).then(function(result) {
return result.json();
});
};
</script>
</head>
<body>
<button id="checkout">Subscribe</button>
<script>
var PRICE_ID = 'price_1IvlmiLx4fybOTqJMKNZhLh2';
function handleResult() {
console.info('Redirected to checkout');
}
document
.getElementById("checkout")
.addEventListener("click", function(evt) {
evt.preventDefault();
// You'll have to define PRICE_ID as a price ID before this code block
createCheckoutSession(PRICE_ID).then(function(data) {
// Call Stripe.js method to redirect to the new Checkout page
stripe
.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
});
</script>
</body>
`;
},
};
router.post('stripe/:id', async (path: SubPath, ctx: AppContext) => {
if (!postHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
return postHandlers[path.id](initStripe(), path, ctx);
});
router.get('stripe/:id', async (path: SubPath, ctx: AppContext) => {
if (!getHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
return getHandlers[path.id](initStripe(), path, ctx);
});
export default router;

View File

@ -10,7 +10,7 @@ import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
const prettyBytes = require('pretty-bytes');
import { formatBytes } from '../../utils/bytes';
interface CheckPasswordInput {
password: string;
@ -71,7 +71,7 @@ router.get('users', async (_path: SubPath, ctx: AppContext) => {
view.content.users = users.map(user => {
return {
...user,
formattedItemMaxSize: user.max_item_size ? prettyBytes(user.max_item_size) : '∞',
formattedItemMaxSize: user.max_item_size ? formatBytes(user.max_item_size) : '∞',
};
});
return view;

View File

@ -18,6 +18,7 @@ import indexNotifications from './index/notifications';
import indexSignup from './index/signup';
import indexShares from './index/shares';
import indexUsers from './index/users';
import indexStripe from './index/stripe';
import defaultRoute from './default';
@ -40,6 +41,7 @@ const routes: Routers = {
'signup': indexSignup,
'shares': indexShares,
'users': indexUsers,
'stripe': indexStripe,
'': defaultRoute,
};

View File

@ -5,6 +5,7 @@ import Mail = require('nodemailer/lib/mailer');
import { createTransport } from 'nodemailer';
import { Email, EmailSender } from '../db';
import { errorToString } from '../utils/errors';
import MarkdownIt = require('markdown-it');
const logger = Logger.create('EmailService');
@ -31,6 +32,7 @@ export default class EmailService extends BaseService {
try {
await this.transport_.verify();
logger.info('Transporter is operational - service will be enabled');
} catch (error) {
this.enabled_ = false;
this.transport_ = null;
@ -53,6 +55,16 @@ export default class EmailService extends BaseService {
throw new Error(`Invalid sender ID: ${senderId}`);
}
private markdownBodyToPlainText(md: string): string {
// Just convert the links to plain URLs
return md.replace(/\[.*\]\((.*)\)/g, '$1');
}
private markdownBodyToHtml(md: string): string {
const markdownIt = new MarkdownIt();
return markdownIt.render(md);
}
private escapeEmailField(f: string): string {
return f.replace(/[\n\r"<>]/g, '');
}
@ -82,7 +94,8 @@ export default class EmailService extends BaseService {
from: this.formatNameAndEmail(sender.email, sender.name),
to: this.formatNameAndEmail(email.recipient_email, email.recipient_name),
subject: email.subject,
text: email.body,
text: this.markdownBodyToPlainText(email.body),
html: this.markdownBodyToHtml(email.body),
};
const emailToSave: Email = {
@ -115,7 +128,11 @@ export default class EmailService extends BaseService {
return;
}
UserModel.eventEmitter.on('created', this.scheduleMaintenance);
UserModel.eventEmitter.on('created', () => {
logger.info('User was created - scheduling maintenance');
void this.scheduleMaintenance();
});
await super.runInBackground();
}

View File

@ -16,7 +16,7 @@ export async function createTestUsers(db: DbConnection, config: Config) {
await dropTables(db);
await migrateDb(db);
const models = newModelFactory(db, config.baseUrl);
const models = newModelFactory(db, config);
for (let userNum = 1; userNum <= 2; userNum++) {
await models.user().save({

View File

@ -45,6 +45,8 @@ const propertyTypes: Record<string, string> = {
'share_users.status': 'ShareUserStatus',
'emails.sender_id': 'EmailSender',
'emails.sent_time': 'number',
'subscriptions.last_payment_time': 'number',
'subscriptions.last_payment_failed_time': 'number',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {

View File

@ -0,0 +1,17 @@
import { GB, KB, MB, formatBytes } from './bytes';
describe('bytes', function() {
it('should convert bytes', async function() {
expect(1 * KB).toBe(1024);
expect(1 * MB).toBe(1048576);
expect(1 * GB).toBe(1073741824);
});
it('should display pretty bytes', async function() {
expect(formatBytes(100 * KB)).toBe('100 kB');
expect(formatBytes(200 * MB)).toBe('200 MB');
expect(formatBytes(3 * GB)).toBe('3 GB');
});
});

View File

@ -0,0 +1,11 @@
const prettyBytes = require('pretty-bytes');
export const KB = 1024;
export const MB = KB * KB;
export const GB = KB * MB;
export function formatBytes(bytes: number): string {
// To simplify we display the result with SI prefix, but removes the "i".
// So 1024 bytes = 1 kB (and not 1 kiB)
return prettyBytes(bytes, { binary: true }).replace(/i/g, '');
}

View File

@ -26,7 +26,7 @@ async function setupServices(env: Env, models: Models, config: Config): Promise<
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.models = newModelFactory(appContext.db, config());
appContext.services = await setupServices(env, appContext.models, config());
appContext.appLogger = appLogger;
appContext.routes = { ...routes };

View File

@ -66,7 +66,7 @@ export async function beforeAllDb(unitName: string) {
// Uncomment the code below to run the test units with Postgres. Run first
// `docker-compose -f docker-compose.db-dev.yml` to get a dev db.
// await initConfig({
// await initConfig(Env.Dev, {
// DB_CLIENT: 'pg',
// POSTGRES_DATABASE: unitName,
// POSTGRES_USER: 'joplin',
@ -75,7 +75,7 @@ export async function beforeAllDb(unitName: string) {
// tempDir: tempDir,
// });
await initConfig({
await initConfig(Env.Dev, {
SQLITE_DATABASE: createdDbPath_,
}, {
tempDir: tempDir,
@ -217,12 +217,12 @@ export function db() {
return db_;
}
function baseUrl() {
return 'http://localhost:22300';
}
// function baseUrl() {
// return 'http://localhost:22300';
// }
export function models() {
return modelFactory(db(), baseUrl());
return modelFactory(db(), config());
}
export function parseHtml(html: string): Document {
@ -367,6 +367,23 @@ export function checkContextError(context: AppContext) {
}
}
export async function credentialFile(filename: string): Promise<string> {
const filePath = `${require('os').homedir()}/joplin-credentials/${filename}`;
if (await fs.pathExists(filePath)) return filePath;
return '';
}
export async function readCredentialFile(filename: string, defaultValue: string = null) {
const filePath = await credentialFile(filename);
if (!filePath) {
if (defaultValue === null) throw new Error(`File not found: ${filename}`);
return defaultValue;
}
const r = await fs.readFile(filePath);
return r.toString();
}
export async function checkThrowAsync(asyncFn: Function): Promise<any> {
try {
await asyncFn();

View File

@ -57,9 +57,16 @@ export interface MailerConfig {
noReplyEmail: string;
}
export interface StripeConfig {
secretKey: string;
publishableKey: string;
webhookSecret: string;
}
export interface Config {
appVersion: string;
appName: string;
env: Env;
port: number;
rootDir: string;
viewDir: string;
@ -73,6 +80,7 @@ export interface Config {
userContentBaseUrl: string;
database: DatabaseConfig;
mailer: MailerConfig;
stripe: StripeConfig;
}
export enum HttpMethod {