You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-17 00:33:59 +02:00
Compare commits
7 Commits
v2.4.1
...
server_use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8411e1270e | ||
|
|
e9d4a777fd | ||
|
|
85984f1f39 | ||
|
|
3c0524c6e9 | ||
|
|
769d47a768 | ||
|
|
87fe0e4dcf | ||
|
|
2513e0aaab |
@@ -32,7 +32,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.2",
|
||||
"bin": {
|
||||
"joplin": "./main.js"
|
||||
},
|
||||
@@ -40,8 +40,8 @@
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/renderer": "~2.4",
|
||||
"@joplin/lib": "~2.3",
|
||||
"@joplin/renderer": "~2.3",
|
||||
"aws-sdk": "^2.588.0",
|
||||
"chalk": "^4.1.0",
|
||||
"compare-version": "^0.1.2",
|
||||
@@ -65,7 +65,7 @@
|
||||
"yargs-parser": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.4",
|
||||
"@joplin/tools": "~2.3",
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": "script-src 'self'; object-src 'self'",
|
||||
|
||||
4
packages/app-desktop/package-lock.json
generated
4
packages/app-desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.1",
|
||||
"version": "2.3.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.1",
|
||||
"version": "2.3.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.4.1",
|
||||
"version": "2.3.5",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.4",
|
||||
"@joplin/tools": "~2.3",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
@@ -122,8 +122,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/renderer": "~2.4",
|
||||
"@joplin/lib": "~2.3",
|
||||
"@joplin/renderer": "~2.3",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
"color": "^3.1.2",
|
||||
|
||||
@@ -142,7 +142,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097648
|
||||
versionName "2.4.0"
|
||||
versionName "2.3.4"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.4.0;
|
||||
MARKETING_VERSION = 12.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -519,7 +519,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.4.0;
|
||||
MARKETING_VERSION = 12.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -666,7 +666,7 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.4.0;
|
||||
MARKETING_VERSION = 12.3.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
@@ -697,7 +697,7 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.4.0;
|
||||
MARKETING_VERSION = 12.3.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -488,7 +488,7 @@ SPEC CHECKSUMS:
|
||||
boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c
|
||||
DoubleConversion: cf9b38bf0b2d048436d9a82ad2abe1404f11e7de
|
||||
FBLazyVector: e686045572151edef46010a6f819ade377dfeb4b
|
||||
FBReactNativeSpec: 6da2c8ff1ebe6b6cf4510fcca58c24c4d02b16fc
|
||||
FBReactNativeSpec: d2f54de51f69366bd1f5c1fb9270698dce678f8d
|
||||
glog: 73c2498ac6884b13ede40eda8228cb1eee9d9d62
|
||||
JoplinCommonShareExtension: 270b4f8eb4e22828eeda433a04ed689fc1fd09b5
|
||||
JoplinRNShareExtension: 7137e9787374e1b0797ecbef9103d1588d90e403
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "<%= pluginId %>",
|
||||
"app_min_version": "2.4",
|
||||
"app_min_version": "2.3",
|
||||
"version": "1.0.0",
|
||||
"name": "<%= pluginName %>",
|
||||
"description": "<%= pluginDescription %>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.0",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
|
||||
"author": {
|
||||
|
||||
4
packages/lib/package-lock.json
generated
4
packages/lib/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"async-mutex": "^0.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Joplin Core library",
|
||||
"author": "Laurent Cozic",
|
||||
"homepage": "",
|
||||
@@ -27,7 +27,7 @@
|
||||
"dependencies": {
|
||||
"@joplin/fork-htmlparser2": "^4.1.33",
|
||||
"@joplin/fork-sax": "^1.2.37",
|
||||
"@joplin/renderer": "~2.4",
|
||||
"@joplin/renderer": "^2.3.1",
|
||||
"@joplin/turndown": "^4.0.55",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.37",
|
||||
"async-mutex": "^0.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/plugin-repo-cli",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
@@ -18,8 +18,8 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/tools": "~2.4",
|
||||
"@joplin/lib": "^2.3.1",
|
||||
"@joplin/tools": "^2.3.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"gh-release-assets": "^2.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
|
||||
4
packages/renderer/package-lock.json
generated
4
packages/renderer/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"font-awesome-filetypes": "^2.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"description": "The Joplin note renderer, used the mobile and desktop application",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/renderer": "~2.4",
|
||||
"@joplin/lib": "~2.3",
|
||||
"@joplin/renderer": "~2.3",
|
||||
"@koa/cors": "^3.1.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bulma": "^0.9.1",
|
||||
@@ -51,7 +51,7 @@
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.4",
|
||||
"@joplin/tools": "~2.3",
|
||||
"@rmp135/sql-ts": "^1.7.0",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/jest": "^26.0.15",
|
||||
|
||||
Binary file not shown.
74
packages/server/src/db.test.ts
Normal file
74
packages/server/src/db.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
it('should pass', async function() {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
// import { afterAllTests, beforeAllDb, beforeEachDb, db } from "./utils/testing/testUtils";
|
||||
// import sqlts from '@rmp135/sql-ts';
|
||||
// import config from "./config";
|
||||
// import { connectDb, DbConnection, disconnectDb, migrateDown, migrateList, migrateUp, nextMigration } from "./db";
|
||||
|
||||
// async function dbSchemaSnapshot(db:DbConnection):Promise<any> {
|
||||
// return sqlts.toObject({
|
||||
// client: 'sqlite',
|
||||
// knex: db,
|
||||
// // 'connection': {
|
||||
// // 'filename': config().database.name,
|
||||
// // },
|
||||
// useNullAsDefault: true,
|
||||
// } as any)
|
||||
|
||||
// // return JSON.stringify(definitions);
|
||||
// }
|
||||
|
||||
// describe('db', function() {
|
||||
|
||||
// beforeAll(async () => {
|
||||
// await beforeAllDb('db', { autoMigrate: false });
|
||||
// });
|
||||
|
||||
// afterAll(async () => {
|
||||
// await afterAllTests();
|
||||
// });
|
||||
|
||||
// beforeEach(async () => {
|
||||
// await beforeEachDb();
|
||||
// });
|
||||
|
||||
// it('should allow downgrading schema', async function() {
|
||||
// const ignoreAllBefore = '20210819165350_user_flags';
|
||||
// let startProcessing = false;
|
||||
|
||||
// //console.info(await dbSchemaSnapshot());
|
||||
|
||||
// while (true) {
|
||||
// await migrateUp(db());
|
||||
|
||||
// if (!startProcessing) {
|
||||
// const next = await nextMigration(db());
|
||||
// if (next === ignoreAllBefore) {
|
||||
// startProcessing = true;
|
||||
// } else {
|
||||
// continue;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (!(await nextMigration(db()))) break;
|
||||
|
||||
// // await disconnectDb(db());
|
||||
// // const beforeSchema = await dbSchemaSnapshot(db());
|
||||
// // console.info(beforeSchema);
|
||||
// // await connectDb(db());
|
||||
|
||||
// // await migrateUp(db());
|
||||
// // await migrateDown(db());
|
||||
|
||||
// // const afterSchema = await dbSchemaSnapshot(db());
|
||||
|
||||
// // // console.info(beforeSchema);
|
||||
// // // console.info(afterSchema);
|
||||
|
||||
// // expect(beforeSchema).toEqual(afterSchema);
|
||||
// }
|
||||
// });
|
||||
|
||||
// });
|
||||
@@ -52,6 +52,11 @@ export interface ConnectionCheckResult {
|
||||
connection: DbConnection;
|
||||
}
|
||||
|
||||
export interface Migration {
|
||||
name: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig {
|
||||
const connection: DbConfigConnection = {};
|
||||
|
||||
@@ -167,8 +172,6 @@ export async function migrateList(db: DbConnection, asString: boolean = true) {
|
||||
// ]
|
||||
// ]
|
||||
|
||||
if (!asString) return migrations;
|
||||
|
||||
const formatName = (migrationInfo: any) => {
|
||||
const name = migrationInfo.file ? migrationInfo.file : migrationInfo;
|
||||
|
||||
@@ -177,32 +180,43 @@ export async function migrateList(db: DbConnection, asString: boolean = true) {
|
||||
return s.join('.');
|
||||
};
|
||||
|
||||
interface Line {
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const output: Line[] = [];
|
||||
const output: Migration[] = [];
|
||||
|
||||
for (const s of migrations[0]) {
|
||||
output.push({
|
||||
text: formatName(s),
|
||||
name: formatName(s),
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of migrations[1]) {
|
||||
output.push({
|
||||
text: formatName(s),
|
||||
name: formatName(s),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
output.sort((a, b) => {
|
||||
return a.text < b.text ? -1 : +1;
|
||||
return a.name < b.name ? -1 : +1;
|
||||
});
|
||||
|
||||
return output.map(l => `${l.done ? '✓' : '✗'} ${l.text}`).join('\n');
|
||||
if (!asString) return output;
|
||||
|
||||
return output.map(l => `${l.done ? '✓' : '✗'} ${l.name}`).join('\n');
|
||||
}
|
||||
|
||||
export async function nextMigration(db: DbConnection): Promise<string> {
|
||||
const list = await migrateList(db, false) as Migration[];
|
||||
|
||||
let nextMigration: Migration = null;
|
||||
|
||||
while (list.length) {
|
||||
const migration = list.pop();
|
||||
if (migration.done) return nextMigration ? nextMigration.name : '';
|
||||
nextMigration = migration;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function allTableNames(): string[] {
|
||||
@@ -321,6 +335,15 @@ export enum ChangeType {
|
||||
Delete = 3,
|
||||
}
|
||||
|
||||
export enum UserFlagType {
|
||||
FailedPaymentWarning = 1,
|
||||
FailedPaymentFinal = 2,
|
||||
AccountOverLimit = 3,
|
||||
AccountWithoutSubscription = 4,
|
||||
SubscriptionCancelled = 5,
|
||||
ManuallyDisabled = 6,
|
||||
}
|
||||
|
||||
export enum FileContentType {
|
||||
Any = 1,
|
||||
JoplinItem = 2,
|
||||
@@ -508,6 +531,12 @@ export interface User extends WithDates, WithUuid {
|
||||
enabled?: number;
|
||||
}
|
||||
|
||||
export interface UserFlag extends WithDates {
|
||||
id?: number;
|
||||
user_id?: Uuid;
|
||||
type?: UserFlagType;
|
||||
}
|
||||
|
||||
export const databaseSchema: DatabaseTables = {
|
||||
sessions: {
|
||||
id: { type: 'string' },
|
||||
@@ -665,5 +694,12 @@ export const databaseSchema: DatabaseTables = {
|
||||
total_item_size: { type: 'string' },
|
||||
enabled: { type: 'number' },
|
||||
},
|
||||
user_flags: {
|
||||
id: { type: 'number' },
|
||||
user_id: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
};
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
||||
20
packages/server/src/migrations/20210819165350_user_flags.ts
Normal file
20
packages/server/src/migrations/20210819165350_user_flags.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('user_flags', (table: Knex.CreateTableBuilder) => {
|
||||
table.increments('id').unique().primary().notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.integer('type').defaultTo(0).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('user_flags', (table: Knex.CreateTableBuilder) => {
|
||||
table.unique(['user_id', 'type']);
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('user_flags');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmailSender, Subscription, User, Uuid } from '../db';
|
||||
import { EmailSender, Subscription, User, UserFlagType, Uuid } from '../db';
|
||||
import { ErrorNotFound } from '../utils/errors';
|
||||
import { Day } from '../utils/time';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
@@ -81,13 +81,10 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
|
||||
const user = await this.models().user().load(sub.user_id);
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
if (!user.enabled || !user.can_upload) {
|
||||
await this.models().user().save({
|
||||
id: sub.user_id,
|
||||
enabled: 1,
|
||||
can_upload: 1,
|
||||
});
|
||||
}
|
||||
await this.models().userFlag().removeMulti(user.id, [
|
||||
UserFlagType.FailedPaymentWarning,
|
||||
UserFlagType.FailedPaymentFinal,
|
||||
]);
|
||||
|
||||
await this.save({
|
||||
id: sub.id,
|
||||
|
||||
42
packages/server/src/models/UserFlagModel.test.ts
Normal file
42
packages/server/src/models/UserFlagModel.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { UserFlagType } from '../db';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, models, createUserAndSession } from '../utils/testing/testUtils';
|
||||
|
||||
describe('UserFlagModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('UserFlagModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should create no more than one flag per type', async function() {
|
||||
const { user } = await createUserAndSession(1);
|
||||
|
||||
const beforeTime = Date.now();
|
||||
await models().userFlag().add(user.id, UserFlagType.AccountOverLimit);
|
||||
const flag = await models().userFlag().byUserId(user.id, UserFlagType.AccountOverLimit);
|
||||
|
||||
expect(flag.user_id).toBe(user.id);
|
||||
expect(flag.type).toBe(UserFlagType.AccountOverLimit);
|
||||
expect(flag.created_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(flag.updated_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
|
||||
const flagCountBefore = (await models().userFlag().all()).length;
|
||||
await models().userFlag().add(user.id, UserFlagType.AccountOverLimit);
|
||||
const flagCountAfter = (await models().userFlag().all()).length;
|
||||
expect(flagCountBefore).toBe(flagCountAfter);
|
||||
|
||||
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
|
||||
const flagCountAfter2 = (await models().userFlag().all()).length;
|
||||
expect(flagCountAfter2).toBe(flagCountBefore + 1);
|
||||
const differentFlag = await models().userFlag().byUserId(user.id, UserFlagType.FailedPaymentFinal);
|
||||
expect(flag.id).not.toBe(differentFlag.id);
|
||||
});
|
||||
|
||||
});
|
||||
130
packages/server/src/models/UserFlagModel.ts
Normal file
130
packages/server/src/models/UserFlagModel.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { isUniqueConstraintError, User, UserFlag, UserFlagType, Uuid } from '../db';
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
interface AddRemoveOptions {
|
||||
updateUser?: boolean;
|
||||
}
|
||||
|
||||
function defaultAddRemoveOptions(): AddRemoveOptions {
|
||||
return {
|
||||
updateUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default class UserFlagModels extends BaseModel<UserFlag> {
|
||||
|
||||
public get tableName(): string {
|
||||
return 'user_flags';
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async add(userId: Uuid, type: UserFlagType, options: AddRemoveOptions = {}): Promise<void> {
|
||||
options = {
|
||||
...defaultAddRemoveOptions(),
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.save({
|
||||
user_id: userId,
|
||||
type,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.updateUser) await this.updateUserFromFlags(userId);
|
||||
}
|
||||
|
||||
public async remove(userId: Uuid, type: UserFlagType, options: AddRemoveOptions = null) {
|
||||
options = {
|
||||
...defaultAddRemoveOptions(),
|
||||
...options,
|
||||
};
|
||||
|
||||
await this.db(this.tableName)
|
||||
.where('user_id', '=', userId)
|
||||
.where('type', '=', type)
|
||||
.delete();
|
||||
|
||||
if (options.updateUser) await this.updateUserFromFlags(userId);
|
||||
}
|
||||
|
||||
public async toggle(userId: Uuid, type: UserFlagType, apply: boolean, options: AddRemoveOptions = null) {
|
||||
if (apply) {
|
||||
await this.add(userId, type, options);
|
||||
} else {
|
||||
await this.remove(userId, type, options);
|
||||
}
|
||||
}
|
||||
|
||||
public async addMulti(userId: Uuid, flagTypes: UserFlagType[]) {
|
||||
await this.withTransaction(async () => {
|
||||
for (const flagType of flagTypes) {
|
||||
await this.add(userId, flagType, { updateUser: false });
|
||||
}
|
||||
await this.updateUserFromFlags(userId);
|
||||
});
|
||||
}
|
||||
|
||||
public async removeMulti(userId: Uuid, flagTypes: UserFlagType[]) {
|
||||
await this.withTransaction(async () => {
|
||||
for (const flagType of flagTypes) {
|
||||
await this.remove(userId, flagType, { updateUser: false });
|
||||
}
|
||||
await this.updateUserFromFlags(userId);
|
||||
});
|
||||
}
|
||||
|
||||
// As a general rule the `enabled` and `can_upload` properties should not
|
||||
// be set directly (except maybe in tests) - instead the appropriate user
|
||||
// flags should be set, and this function will derive the enabled/can_upload
|
||||
// properties from them.
|
||||
private async updateUserFromFlags(userId: Uuid) {
|
||||
const flags = await this.allByUserId(userId);
|
||||
const user = await this.models().user().load(userId, { fields: ['id', 'can_upload', 'enabled'] });
|
||||
|
||||
const newProps: User = {
|
||||
can_upload: 1,
|
||||
enabled: 1,
|
||||
};
|
||||
|
||||
if (flags.find(f => f.type === UserFlagType.AccountWithoutSubscription)) {
|
||||
newProps.can_upload = 0;
|
||||
} else if (flags.find(f => f.type === UserFlagType.AccountOverLimit)) {
|
||||
newProps.can_upload = 0;
|
||||
} else if (flags.find(f => f.type === UserFlagType.FailedPaymentWarning)) {
|
||||
newProps.can_upload = 0;
|
||||
} else if (flags.find(f => f.type === UserFlagType.FailedPaymentFinal)) {
|
||||
newProps.enabled = 0;
|
||||
} else if (flags.find(f => f.type === UserFlagType.SubscriptionCancelled)) {
|
||||
newProps.enabled = 0;
|
||||
} else if (flags.find(f => f.type === UserFlagType.ManuallyDisabled)) {
|
||||
newProps.enabled = 0;
|
||||
}
|
||||
|
||||
if (user.can_upload !== newProps.can_upload || user.enabled !== newProps.enabled) {
|
||||
await this.models().user().save({
|
||||
id: userId,
|
||||
...newProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async byUserId(userId: Uuid, type: UserFlagType): Promise<UserFlag> {
|
||||
return this.db(this.tableName)
|
||||
.where('user_id', '=', userId)
|
||||
.where('type', '=', type)
|
||||
.first();
|
||||
}
|
||||
|
||||
public async allByUserId(userId: Uuid): Promise<UserFlag[]> {
|
||||
return this.db(this.tableName).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 { EmailSender, User, UserFlagType } from '../db';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
import { AccountType } from './UserModel';
|
||||
@@ -160,6 +160,9 @@ describe('UserModel', function() {
|
||||
|
||||
const reloadedUser = await models().user().load(user1.id);
|
||||
expect(reloadedUser.can_upload).toBe(0);
|
||||
|
||||
const userFlag = await models().userFlag().byUserId(user1.id, UserFlagType.AccountWithoutSubscription);
|
||||
expect(userFlag).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should disable upload and send an email if payment failed', async function() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel';
|
||||
import { EmailSender, Item, User, Uuid } from '../db';
|
||||
import { EmailSender, Item, User, UserFlagType, Uuid } from '../db';
|
||||
import * as auth from '../utils/auth';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
@@ -245,16 +245,6 @@ 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);
|
||||
|
||||
@@ -314,10 +304,6 @@ export default class UserModel extends BaseModel<User> {
|
||||
await this.models().token().deleteByValue(user.id, token);
|
||||
}
|
||||
|
||||
// public async disableUnpaidAccounts() {
|
||||
|
||||
// }
|
||||
|
||||
public async handleBetaUserEmails() {
|
||||
if (!stripeConfig().enabled) return;
|
||||
|
||||
@@ -355,7 +341,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
}
|
||||
|
||||
if (remainingDays <= 0) {
|
||||
await this.save({ id: user.id, can_upload: 0 });
|
||||
await this.models().userFlag().add(user.id, UserFlagType.AccountWithoutSubscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,7 +358,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.save({ id: user.id, can_upload: 0 });
|
||||
await this.models().userFlag().add(user.id, UserFlagType.FailedPaymentWarning);
|
||||
|
||||
await this.models().email().push({
|
||||
...paymentFailedUploadDisabledTemplate(),
|
||||
|
||||
@@ -69,6 +69,7 @@ import ShareUserModel from './ShareUserModel';
|
||||
import KeyValueModel from './KeyValueModel';
|
||||
import TokenModel from './TokenModel';
|
||||
import SubscriptionModel from './SubscriptionModel';
|
||||
import UserFlagModel from './UserFlagModel';
|
||||
import { Config } from '../utils/types';
|
||||
|
||||
export class Models {
|
||||
@@ -137,6 +138,10 @@ export class Models {
|
||||
return new SubscriptionModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
public userFlag() {
|
||||
return new UserFlagModel(this.db_, newModelFactory, this.config_);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default function newModelFactory(db: DbConnection, config: Config): Models {
|
||||
|
||||
@@ -844,7 +844,10 @@ describe('shares.folder', function() {
|
||||
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 models().user().save({
|
||||
id: user2.id,
|
||||
enabled: 0,
|
||||
});
|
||||
|
||||
await expectHttpError(async () =>
|
||||
shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
|
||||
|
||||
@@ -174,7 +174,10 @@ describe('shares.link', function() {
|
||||
note_id: noteItem.jop_id,
|
||||
});
|
||||
|
||||
await models().user().disable(user.id);
|
||||
await models().user().save({
|
||||
id: user.id,
|
||||
enabled: 0,
|
||||
});
|
||||
|
||||
await expectHttpError(async () => getShareContent(share.id), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
|
||||
import { UserFlagType } from '../../db';
|
||||
import { AccountType } from '../../models/UserModel';
|
||||
import { betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../../utils/stripe';
|
||||
import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils';
|
||||
@@ -191,5 +192,26 @@ describe('index/stripe', function() {
|
||||
}
|
||||
});
|
||||
|
||||
test('should re-enable account if successful payment is made', async function() {
|
||||
const stripe = mockStripe();
|
||||
const ctx = await koaAppContext();
|
||||
|
||||
await createUserViaSubscription(ctx, 'toto@example.com', { stripe, subscriptionId: 'sub_init' });
|
||||
let user = (await models().user().all())[0];
|
||||
await models().user().save({
|
||||
id: user.id,
|
||||
enabled: 0,
|
||||
can_upload: 0,
|
||||
});
|
||||
|
||||
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
|
||||
|
||||
await simulateWebhook(ctx, 'invoice.paid', { subscription: 'sub_init' });
|
||||
|
||||
user = await models().user().load(user.id);
|
||||
|
||||
expect(user.enabled).toBe(1);
|
||||
expect(user.can_upload).toBe(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import Logger from '@joplin/lib/Logger';
|
||||
import getRawBody = require('raw-body');
|
||||
import { AccountType } from '../../models/UserModel';
|
||||
import { betaUserTrialPeriodDays, cancelSubscription, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
|
||||
import { Subscription } from '../../db';
|
||||
import { Subscription, UserFlagType } from '../../db';
|
||||
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
|
||||
|
||||
const logger = Logger.create('/stripe');
|
||||
@@ -250,15 +250,21 @@ export const postHandlers: PostHandlers = {
|
||||
logger.info(`Setting up subscription for existing user: ${existingUser.email}`);
|
||||
|
||||
// First set the account type correctly (in case the
|
||||
// user also upgraded or downgraded their account). Also
|
||||
// re-enable upload if it was disabled.
|
||||
// user also upgraded or downgraded their account).
|
||||
await models.user().save({
|
||||
id: existingUser.id,
|
||||
account_type: accountType,
|
||||
can_upload: 1,
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
// Also clear any payment and subscription related flags
|
||||
// since if we're here it means payment was successful
|
||||
await models.userFlag().removeMulti(existingUser.id, [
|
||||
UserFlagType.FailedPaymentWarning,
|
||||
UserFlagType.FailedPaymentFinal,
|
||||
UserFlagType.SubscriptionCancelled,
|
||||
UserFlagType.AccountWithoutSubscription,
|
||||
]);
|
||||
|
||||
// Then save the subscription
|
||||
await models.subscription().save({
|
||||
user_id: existingUser.id,
|
||||
@@ -319,8 +325,8 @@ export const postHandlers: PostHandlers = {
|
||||
// by the user. In that case, we disable the user.
|
||||
|
||||
const { sub } = await getSubscriptionInfo(event, ctx);
|
||||
await models.user().enable(sub.user_id, false);
|
||||
await models.subscription().toggleSoftDelete(sub.id, true);
|
||||
await models.userFlag().add(sub.user_id, UserFlagType.SubscriptionCancelled);
|
||||
},
|
||||
|
||||
'customer.subscription.updated': async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RouteType } from '../../utils/types';
|
||||
import { AppContext, HttpMethod } from '../../utils/types';
|
||||
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
|
||||
import { User, Uuid } from '../../db';
|
||||
import { User, UserFlagType, Uuid } from '../../db';
|
||||
import config from '../../config';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
@@ -273,40 +273,41 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
|
||||
if (userIsMe(path)) fields.id = userId;
|
||||
user = makeUser(isNew, fields);
|
||||
|
||||
const userModel = ctx.joplin.models.user();
|
||||
const models = ctx.joplin.models;
|
||||
|
||||
if (fields.post_button) {
|
||||
const userToSave: User = userModel.fromApiInput(user);
|
||||
await userModel.checkIfAllowed(ctx.joplin.owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
const userToSave: User = models.user().fromApiInput(user);
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
|
||||
|
||||
if (isNew) {
|
||||
await userModel.save(userToSave);
|
||||
await models.user().save(userToSave);
|
||||
} else {
|
||||
await userModel.save(userToSave, { isNew: false });
|
||||
await models.user().save(userToSave, { isNew: false });
|
||||
}
|
||||
} else if (fields.user_cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(ctx.joplin.models, userId);
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
const sessionId = contextSessionId(ctx, false);
|
||||
if (sessionId) {
|
||||
await ctx.joplin.models.session().logout(sessionId);
|
||||
await models.session().logout(sessionId);
|
||||
return redirect(ctx, config().baseUrl);
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Delete, user);
|
||||
|
||||
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !!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);
|
||||
const user = await models.user().load(path.id);
|
||||
await models.user().save({ id: user.id, must_set_password: 1 });
|
||||
await models.user().sendAccountConfirmationEmail(user);
|
||||
} else if (fields.cancel_subscription_button) {
|
||||
await cancelSubscriptionByUserId(ctx.joplin.models, userId);
|
||||
await cancelSubscriptionByUserId(models, userId);
|
||||
} else if (fields.update_subscription_basic_button) {
|
||||
await updateSubscriptionType(ctx.joplin.models, userId, AccountType.Basic);
|
||||
await updateSubscriptionType(models, userId, AccountType.Basic);
|
||||
} else if (fields.update_subscription_pro_button) {
|
||||
await updateSubscriptionType(ctx.joplin.models, userId, AccountType.Pro);
|
||||
await updateSubscriptionType(models, userId, AccountType.Pro);
|
||||
} else {
|
||||
throw new Error('Invalid form button');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { DatabaseConfig } from '../utils/types';
|
||||
const { execCommand } = require('@joplin/tools/tool-utils');
|
||||
|
||||
export interface CreateDbOptions {
|
||||
dropIfExists: boolean;
|
||||
dropIfExists?: boolean;
|
||||
autoMigrate?: boolean;
|
||||
}
|
||||
|
||||
export interface DropDbOptions {
|
||||
@@ -15,6 +16,7 @@ export interface DropDbOptions {
|
||||
export async function createDb(config: DatabaseConfig, options: CreateDbOptions = null) {
|
||||
options = {
|
||||
dropIfExists: false,
|
||||
autoMigrate: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
@@ -46,7 +48,7 @@ export async function createDb(config: DatabaseConfig, options: CreateDbOptions
|
||||
|
||||
try {
|
||||
const db = await connectDb(config);
|
||||
await migrateLatest(db);
|
||||
if (options.autoMigrate) await migrateLatest(db);
|
||||
await disconnectDb(db);
|
||||
} catch (error) {
|
||||
error.message += `: ${config.name}`;
|
||||
|
||||
@@ -22,36 +22,38 @@ const config = {
|
||||
'tableNameCasing': 'pascal' as any,
|
||||
'filename': './db',
|
||||
'extends': {
|
||||
'main.sessions': 'WithDates, WithUuid',
|
||||
'main.users': 'WithDates, WithUuid',
|
||||
'main.items': 'WithDates, WithUuid',
|
||||
'main.api_clients': 'WithDates, WithUuid',
|
||||
'main.changes': 'WithDates, WithUuid',
|
||||
'main.notifications': 'WithDates, WithUuid',
|
||||
'main.shares': 'WithDates, WithUuid',
|
||||
'main.share_users': 'WithDates, WithUuid',
|
||||
'main.user_items': 'WithDates',
|
||||
'main.emails': 'WithDates',
|
||||
'main.items': 'WithDates, WithUuid',
|
||||
'main.notifications': 'WithDates, WithUuid',
|
||||
'main.sessions': 'WithDates, WithUuid',
|
||||
'main.share_users': 'WithDates, WithUuid',
|
||||
'main.shares': 'WithDates, WithUuid',
|
||||
'main.tokens': 'WithDates',
|
||||
'main.user_flags': 'WithDates',
|
||||
'main.user_items': 'WithDates',
|
||||
'main.users': 'WithDates, WithUuid',
|
||||
},
|
||||
};
|
||||
|
||||
const propertyTypes: Record<string, string> = {
|
||||
'*.item_type': 'ItemType',
|
||||
'changes.type': 'ChangeType',
|
||||
'notifications.level': 'NotificationLevel',
|
||||
'shares.type': 'ShareType',
|
||||
'items.content': 'Buffer',
|
||||
'items.jop_updated_time': 'number',
|
||||
'share_users.status': 'ShareUserStatus',
|
||||
'emails.sender_id': 'EmailSender',
|
||||
'emails.sent_time': 'number',
|
||||
'subscriptions.last_payment_time': 'number',
|
||||
'items.content': 'Buffer',
|
||||
'items.jop_updated_time': 'number',
|
||||
'notifications.level': 'NotificationLevel',
|
||||
'share_users.status': 'ShareUserStatus',
|
||||
'shares.type': 'ShareType',
|
||||
'subscriptions.last_payment_failed_time': 'number',
|
||||
'subscriptions.last_payment_time': 'number',
|
||||
'user_flags.type': 'UserFlagType',
|
||||
'users.can_share_folder': 'number | null',
|
||||
'users.can_share_note': 'number | null',
|
||||
'users.max_total_item_size': 'number | null',
|
||||
'users.max_item_size': 'number | null',
|
||||
'users.max_total_item_size': 'number | null',
|
||||
'users.total_item_size': 'number',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User, Session, DbConnection, connectDb, disconnectDb, truncateTables, Item, Uuid } from '../../db';
|
||||
import { createDb } from '../../tools/dbTools';
|
||||
import { createDb, CreateDbOptions } from '../../tools/dbTools';
|
||||
import modelFactory from '../../models/factory';
|
||||
import { AppContext, Env } from '../types';
|
||||
import config, { initConfig } from '../../config';
|
||||
@@ -60,7 +60,7 @@ function initGlobalLogger() {
|
||||
}
|
||||
|
||||
let createdDbPath_: string = null;
|
||||
export async function beforeAllDb(unitName: string) {
|
||||
export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOptions = null) {
|
||||
unitName = unitName.replace(/\//g, '_');
|
||||
|
||||
createdDbPath_ = `${packageRootDir}/db-test-${unitName}.sqlite`;
|
||||
@@ -89,7 +89,7 @@ export async function beforeAllDb(unitName: string) {
|
||||
|
||||
initGlobalLogger();
|
||||
|
||||
await createDb(config().database, { dropIfExists: true });
|
||||
await createDb(config().database, { dropIfExists: true, ...createDbOptions });
|
||||
db_ = await connectDb(config().database);
|
||||
|
||||
const mustache = new MustacheService(config().viewDir, config().baseUrl);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/tools",
|
||||
"version": "2.4.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Various tools for Joplin",
|
||||
"main": "index.js",
|
||||
"author": "Laurent Cozic",
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/lib": "^2.3.1",
|
||||
"execa": "^4.1.0",
|
||||
"fs-extra": "^4.0.3",
|
||||
"gettext-parser": "^1.3.0",
|
||||
|
||||
Reference in New Issue
Block a user