1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-24 08:12:24 +02:00

Server: Allow enabling or disabling a user. Handle cancelling subscription.

This commit is contained in:
Laurent Cozic 2021-07-22 17:32:10 +01:00
parent 2b378880ce
commit 27c3cbdf8f
19 changed files with 208 additions and 49 deletions

Binary file not shown.

View File

@ -132,7 +132,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
const packageJson = await readPackageJson(`${rootDir}/package.json`);
const stripePublicConfigs = JSON.parse(await readFile(`${rootDir}/stripeConfig.json`, 'utf8'));
const stripePublicConfig = stripePublicConfigs[envType];
const stripePublicConfig = stripePublicConfigs[envType === Env.BuildTypes ? Env.Dev : envType];
if (!stripePublicConfig) throw new Error('Could not load Stripe config');
const viewDir = `${rootDir}/src/views`;

View File

@ -411,6 +411,7 @@ export interface Subscription {
last_payment_failed_time?: number;
updated_time?: string;
created_time?: string;
is_deleted?: number;
}
export interface User extends WithDates, WithUuid {
@ -427,6 +428,7 @@ export interface User extends WithDates, WithUuid {
can_share_note?: number | null;
max_total_item_size?: number | null;
total_item_size?: number;
enabled?: number;
}
export const databaseSchema: DatabaseTables = {
@ -564,6 +566,7 @@ export const databaseSchema: DatabaseTables = {
last_payment_failed_time: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
is_deleted: { type: 'number' },
},
users: {
id: { type: 'string' },
@ -582,6 +585,7 @@ export const databaseSchema: DatabaseTables = {
can_share_note: { type: 'number' },
max_total_item_size: { type: 'string' },
total_item_size: { type: 'string' },
enabled: { type: 'number' },
},
};
// AUTO-GENERATED-TYPES

View File

@ -1,4 +1,5 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { ErrorForbidden } from '../utils/errors';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext, models, expectHttpError } from '../utils/testing/testUtils';
import ownerHandler from './ownerHandler';
describe('ownerHandler', function() {
@ -44,4 +45,18 @@ describe('ownerHandler', function() {
expect(!!context.joplin.owner).toBe(false);
});
test('should not login if the user has been disabled', async function() {
const { user, session } = await createUserAndSession(1);
await models().user().save({ id: user.id, enabled: 0 });
const context = await koaAppContext({
sessionId: session.id,
});
context.joplin.owner = null;
await expectHttpError(async () => ownerHandler(context, koaNext), ErrorForbidden.httpCode);
});
});

View File

@ -1,8 +1,11 @@
import { AppContext, KoaNext } from '../utils/types';
import { contextSessionId } from '../utils/requestUtils';
import { ErrorForbidden } from '../utils/errors';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
const sessionId = contextSessionId(ctx, false);
ctx.joplin.owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
const owner = sessionId ? await ctx.joplin.models.session().sessionUser(sessionId) : null;
if (owner && !owner.enabled) throw new ErrorForbidden('This user account is disabled. Please contact support.');
ctx.joplin.owner = owner;
return next();
}

View File

@ -0,0 +1,16 @@
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.specificType('enabled', 'smallint').defaultTo(1).nullable();
});
await db.schema.alterTable('subscriptions', function(table: Knex.CreateTableBuilder) {
table.specificType('is_deleted', 'smallint').defaultTo(0).nullable();
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@ -11,7 +11,8 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
public async checkIfAllowed(user: User, action: AclAction, resource: ShareUser = null): Promise<void> {
if (action === AclAction.Create) {
const recipient = await this.models().user().load(resource.user_id, { fields: ['account_type', 'can_share_folder'] });
const recipient = await this.models().user().load(resource.user_id, { fields: ['account_type', 'can_share_folder', 'enabled'] });
if (!recipient.enabled) throw new ErrorForbidden('the recipient account is disabled');
if (!getCanShareFolder(recipient)) throw new ErrorForbidden('The sharing feature is not enabled for the recipient account');
const share = await this.models().share().load(resource.share_id);

View File

@ -43,11 +43,11 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
}
public async byStripeSubscriptionId(id: string): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).first();
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).where('is_deleted', '=', 0).first();
}
public async byUserId(userId: Uuid): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).first();
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).where('is_deleted', '=', 0).first();
}
public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
@ -71,4 +71,10 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
});
}
public async toggleSoftDelete(id: number, isDeleted: boolean) {
const sub = await this.load(`${id}`);
if (!sub) throw new Error(`No such subscription: ${id}`);
await this.save({ id, is_deleted: isDeleted ? 1 : 0 });
}
}

View File

@ -96,6 +96,7 @@ export default class UserModel extends BaseModel<User> {
public async login(email: string, password: string): Promise<User> {
const user = await this.loadByEmail(email);
if (!user) return null;
if (!user.enabled) throw new ErrorForbidden('This account is disabled. Please contact support if you need to re-activate it.');
if (!auth.checkPassword(password, user.password)) return null;
return user;
}
@ -230,6 +231,16 @@ export default class UserModel extends BaseModel<User> {
return !!s[0].length && !!s[1].length;
}
public async enable(id: Uuid, enabled: boolean) {
const user = await this.load(id);
if (!user) throw new ErrorNotFound(`No such user: ${id}`);
await this.save({ id, enabled: enabled ? 1 : 0 });
}
public async disable(id: Uuid) {
await this.enable(id, false);
}
public async delete(id: string): Promise<void> {
const shares = await this.models().share().sharesByUser(id);

View File

@ -841,4 +841,20 @@ describe('shares.folder', function() {
expect(share).toBeTruthy();
});
test('should check permissions - cannot share with a disabled account', async function() {
const { session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
await models().user().disable(user2.id);
await expectHttpError(async () =>
shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
{
id: '000000000000000000000000000000F1',
children: [],
},
]),
ErrorForbidden.httpCode
);
});
});

View File

@ -1,8 +1,9 @@
import { Share, ShareType } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { ErrorForbidden } from '../../utils/errors';
import { postApi } from '../../utils/testing/apiUtils';
import { testImageBuffer } from '../../utils/testing/fileApiUtils';
import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem, models, expectHttpError } from '../../utils/testing/testUtils';
const resourceSize = 2720;
@ -159,4 +160,23 @@ describe('shares.link', function() {
}
});
test('should throw an error if owner of share is disabled', async function() {
const { user, session } = await createUserAndSession();
const noteItem = await createNote(session.id, {
id: '00000000000000000000000000000001',
body: 'testing',
});
const share = await postApi<Share>(session.id, 'shares', {
type: ShareType.Note,
note_id: noteItem.jop_id,
});
await models().user().disable(user.id);
await expectHttpError(async () => getShareContent(share.id), ErrorForbidden.httpCode);
});
});

View File

@ -2,7 +2,7 @@ import { SubPath, ResponseType, Response } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { ErrorNotFound } from '../../utils/errors';
import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
import { Item, Share } from '../../db';
import { ModelType } from '@joplin/lib/BaseModel';
import { FileViewerResponse, renderItem as renderJoplinItem } from '../../utils/joplinUtils';
@ -29,6 +29,9 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
const share = await shareModel.load(path.id);
if (!share) throw new ErrorNotFound();
const user = await ctx.joplin.models.user().load(share.owner_id);
if (!user.enabled) throw new ErrorForbidden('This account has been disabled');
const itemModel = ctx.joplin.models.item();
const item = await itemModel.loadWithContent(share.item_id);

View File

@ -1,6 +1,6 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType, StripeConfig } from '../../utils/types';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils';
import globalConfig from '../../config';
@ -9,7 +9,7 @@ import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
const stripeLib = require('stripe');
import { initStripe, stripeConfig } from '../../utils/stripe';
const logger = Logger.create('/stripe');
@ -17,14 +17,6 @@ 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');
@ -89,7 +81,7 @@ const postHandlers: Record<string, StripeRouteHandler> = {
};
},
// How to test the complete workflow locally:
// # How to test the complete workflow locally
//
// - In website/build.ts, set the env to "dev", then build the website - `npm run watch-website`
// - Start the Stripe CLI tool: `stripe listen --forward-to http://joplincloud.local:22300/stripe/webhook`
@ -98,7 +90,11 @@ const postHandlers: Record<string, StripeRouteHandler> = {
// - Start the workflow from http://localhost:8080/plans/
// - The local website often is not configured to send email, but you can see them in the database, in the "emails" table.
//
// Stripe config:
// # Simplified workflow
//
// To test without running the main website, use http://joplincloud.local:22300/stripe/checkoutTest
//
// # Stripe config
//
// - The public config is under packages/server/stripeConfig.json
// - The private config is in the server .env file
@ -221,11 +217,26 @@ const postHandlers: Record<string, StripeRouteHandler> = {
await ctx.joplin.models.subscription().handlePayment(subId, false);
},
'customer.subscription.deleted': async () => {
// The subscription has been cancelled, either by us or directly
// by the user. In that case, we disable the user.
const stripeSub = event.data.object as Stripe.Subscription;
const sub = await ctx.joplin.models.subscription().byStripeSubscriptionId(stripeSub.id);
if (!sub) throw new Error(`No subscription with ID: ${stripeSub.id}`);
await ctx.joplin.models.user().enable(sub.user_id, false);
await ctx.joplin.models.subscription().toggleSoftDelete(sub.id, true);
},
};
if (hooks[event.type]) {
logger.info(`Got Stripe event: ${event.type} [Handled]`);
await hooks[event.type]();
try {
await hooks[event.type]();
} catch (error) {
logger.error(`Error processing event ${event.type}:`, event, error);
}
} else {
logger.info(`Got Stripe event: ${event.type} [Unhandled]`);
}
@ -295,7 +306,7 @@ const getHandlers: Record<string, StripeRouteHandler> = {
<body>
<button id="checkout">Subscribe</button>
<script>
var PRICE_ID = 'price_1IvlmiLx4fybOTqJMKNZhLh2';
var PRICE_ID = ${JSON.stringify(stripeConfig().basicPriceId)};
function handleResult() {
console.info('Redirected to checkout');

View File

@ -340,10 +340,11 @@ describe('index/users', function() {
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
// only admins can delete users
await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
// Note: Disabled because the entire code is skipped if it's not an admin
// await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// cannot delete own user
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// non-admin cannot change max_item_size
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);

View File

@ -16,6 +16,7 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions } from '../../utils/views/select';
import { confirmUrl } from '../../utils/urlUtils';
import { cancelSubscription } from '../../utils/stripe';
export interface CheckRepeatPasswordInput {
password: string;
@ -136,14 +137,18 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
postUrl = `${config().baseUrl}/users/${user.id}`;
}
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
const view: View = defaultView('user', 'Profile');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.content.showResetPasswordButton = !isNew && owner.is_admin;
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
view.content.showCancelSubscription = !isNew && !!owner.is_admin && owner.id !== user.id && subscription;
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
view.content.canSetEmail = isNew || owner.is_admin;
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
view.jsFiles.push('zxcvbn');
@ -230,7 +235,9 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
interface FormFields {
id: Uuid;
post_button: string;
delete_button: string;
disable_button: string;
restore_button: string;
cancel_subscription_button: string;
send_reset_password_email: string;
}
@ -256,16 +263,22 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
} else {
await userModel.save(userToSave, { isNew: false });
}
} else if (fields.delete_button) {
const user = await userModel.load(path.id);
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
await userModel.delete(path.id);
} else if (fields.send_reset_password_email) {
const user = await userModel.load(path.id);
await userModel.save({ id: user.id, must_set_password: 1 });
await userModel.sendAccountConfirmationEmail(user);
} else {
throw new Error('Invalid form button');
if (ctx.joplin.owner.is_admin) {
if (fields.disable_button || fields.restore_button) {
const user = await userModel.load(path.id);
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
await userModel.enable(path.id, !!fields.restore_button);
} else if (fields.send_reset_password_email) {
const user = await userModel.load(path.id);
await userModel.save({ id: user.id, must_set_password: 1 });
await userModel.sendAccountConfirmationEmail(user);
} else if (fields.cancel_subscription_button) {
await cancelSubscription(ctx.joplin.models, userId);
} else {
throw new Error('Invalid form button');
}
}
}
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : ''}`);

View File

@ -0,0 +1,21 @@
import globalConfig from '../config';
import { StripeConfig } from './types';
import { Stripe } from 'stripe';
import { Uuid } from '../db';
import { Models } from '../models/factory';
const stripeLib = require('stripe');
export function stripeConfig(): StripeConfig {
return globalConfig().stripe;
}
export function initStripe(): Stripe {
return stripeLib(stripeConfig().secretKey);
}
export async function cancelSubscription(models: Models, userId: Uuid) {
const sub = await models.subscription().byUserId(userId);
if (!sub) throw new Error(`No subscription for user: ${userId}`);
const stripe = initStripe();
await stripe.subscriptions.del(sub.stripe_subscription_id);
}

View File

@ -37,14 +37,14 @@ export interface AppContext extends Koa.Context {
// All the properties under `joplin` were previously at the root, so to make
// sure they are no longer used anywhere we set them to "never", as that
// would trigger the TypeScript compiler. Later on, all this can be removed.
env: never;
db: never;
models: never;
appLogger: never;
notifications: never;
owner: never;
routes: never;
services: never;
// env: never;
// db: never;
// models: never;
// appLogger: never;
// notifications: never;
// owner: never;
// routes: never;
// services: never;
}
export enum DatabaseConfigClient {

View File

@ -80,17 +80,33 @@
{{#showResetPasswordButton}}
<input type="submit" name="send_reset_password_email" class="button is-warning" value="Send reset password email" />
{{/showResetPasswordButton}}
{{#showDeleteButton}}
<input type="submit" name="delete_button" class="button is-danger" value="Delete" />
{{/showDeleteButton}}
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showCancelSubscription}}
<input type="submit" name="cancel_subscription_button" class="button is-danger" value="Cancel subscription" />
{{/showCancelSubscription}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
</div>
</form>
<script>
$(() => {
document.getElementById("user_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'delete_button') {
const ok = confirm('Delete this user?');
if (event.submitter.getAttribute('name') === 'disable_button') {
const ok = confirm('Disable this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'restore_button') {
const ok = confirm('Restore this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'cancel_subscription_button') {
const ok = confirm('Cancel this subscription?');
if (!ok) event.preventDefault();
}
});

View File

@ -13,6 +13,7 @@
<th>Max Total Size</th>
<th>Can share</th>
<th>Is admin?</th>
<th>Enabled?</th>
<th>Actions</th>
</tr>
</thead>
@ -27,6 +28,7 @@
<td>{{formattedMaxTotalSize}}</td>
<td>{{formattedCanShareFolder}}</td>
<td>{{is_admin}}</td>
<td>{{enabled}}</td>
<td><a href="{{{global.baseUrl}}}/users/{{id}}" class="button is-primary is-small">Edit</a></td>
</tr>
{{/users}}