You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
27 Commits
server-v2.
...
server-v2.
Author | SHA1 | Date | |
---|---|---|---|
|
8c56cf98be | ||
|
18965494d9 | ||
|
ecd1602658 | ||
|
3c181906c2 | ||
|
9e1e144311 | ||
|
757c125bd3 | ||
|
2867b66cf1 | ||
|
5c6fd93753 | ||
|
ea65313bdb | ||
|
1711f7ec88 | ||
|
e0b5ef6630 | ||
|
4bbb3d1d58 | ||
|
fd769945b1 | ||
|
6e91d2784f | ||
|
881b2f17b1 | ||
|
e83cc58ea6 | ||
|
77def9f782 | ||
|
b23cc5d30a | ||
|
d8119bcf07 | ||
|
8bce259dc9 | ||
|
8a00eef901 | ||
|
31121c86d5 | ||
|
a4a156c7a5 | ||
|
c5b0529968 | ||
|
ba322b1f9b | ||
|
6f27eae7dd | ||
|
85cc08c0d4 |
@@ -980,6 +980,9 @@ packages/lib/models/dateTimeFormats.test.js.map
|
||||
packages/lib/models/settings/FileHandler.d.ts
|
||||
packages/lib/models/settings/FileHandler.js
|
||||
packages/lib/models/settings/FileHandler.js.map
|
||||
packages/lib/models/utils/itemCanBeEncrypted.d.ts
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js.map
|
||||
packages/lib/models/utils/paginatedFeed.d.ts
|
||||
packages/lib/models/utils/paginatedFeed.js
|
||||
packages/lib/models/utils/paginatedFeed.js.map
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -966,6 +966,9 @@ packages/lib/models/dateTimeFormats.test.js.map
|
||||
packages/lib/models/settings/FileHandler.d.ts
|
||||
packages/lib/models/settings/FileHandler.js
|
||||
packages/lib/models/settings/FileHandler.js.map
|
||||
packages/lib/models/utils/itemCanBeEncrypted.d.ts
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js.map
|
||||
packages/lib/models/utils/paginatedFeed.d.ts
|
||||
packages/lib/models/utils/paginatedFeed.js
|
||||
packages/lib/models/utils/paginatedFeed.js.map
|
||||
|
@@ -36,7 +36,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.8.5/Jo
|
||||
|
||||
Operating System | Download | Alt. Download
|
||||
---|---|---
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v1.7.5/joplin-v1.7.5.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v1.7.5/joplin-v1.7.5-32bit.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://joplinapp.org/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.0.4/joplin-v2.0.4.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.0.4/joplin-v2.0.4-32bit.apk)
|
||||
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://joplinapp.org/images/BadgeIOS.png'/></a> | -
|
||||
|
||||
## Terminal application
|
||||
|
@@ -71,20 +71,28 @@ class Command extends BaseCommand {
|
||||
};
|
||||
|
||||
if (args.command === 'enable') {
|
||||
const password = options.password ? options.password.toString() : await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
|
||||
const argPassword = options.password ? options.password.toString() : '';
|
||||
const password = argPassword ? argPassword : await this.prompt(_('Enter master password:'), { type: 'string', secure: true });
|
||||
if (!password) {
|
||||
this.stdout(_('Operation cancelled'));
|
||||
return;
|
||||
}
|
||||
const password2 = await this.prompt(_('Confirm password:'), { type: 'string', secure: true });
|
||||
if (!password2) {
|
||||
this.stdout(_('Operation cancelled'));
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
this.stdout(_('Passwords do not match!'));
|
||||
return;
|
||||
|
||||
// If the password was passed via command line, we don't ask for
|
||||
// confirmation. This is to allow setting up E2EE entirely from the
|
||||
// command line.
|
||||
if (!argPassword) {
|
||||
const password2 = await this.prompt(_('Confirm password:'), { type: 'string', secure: true });
|
||||
if (!password2) {
|
||||
this.stdout(_('Operation cancelled'));
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
this.stdout(_('Passwords do not match!'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await EncryptionService.instance().generateMasterKeyAndEnableEncryption(password);
|
||||
return;
|
||||
}
|
||||
|
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.9",
|
||||
"version": "2.0.10",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.9",
|
||||
"version": "2.0.10",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
@@ -1,48 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup the sync parameters for user X and create a few folders and notes to
|
||||
# allow sharing. Also calls the API to create the test users and clear the data.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
||||
|
||||
if [ "$1" == "" ]; then
|
||||
echo "User number is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_NUM=$1
|
||||
RESET_ALL=$2
|
||||
PROFILE_DIR=~/.config/joplindev-desktop-$USER_NUM
|
||||
|
||||
if [ "$RESET_ALL" == "1" ]; then
|
||||
CMD_FILE="$SCRIPT_DIR/runForSharingCommands-$USER_NUM.txt"
|
||||
rm -f "$CMD_FILE"
|
||||
|
||||
USER_EMAIL="user$USER_NUM@example.com"
|
||||
rm -rf "$PROFILE_DIR"
|
||||
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 10" >> "$CMD_FILE"
|
||||
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.10.password 123456" >> "$CMD_FILE"
|
||||
|
||||
if [ "$USER_NUM" == "1" ]; then
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
|
||||
|
||||
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||
echo 'use "shared"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 1"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 2"' >> "$CMD_FILE"
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
npm start -- --env dev --profile "$PROFILE_DIR"
|
68
packages/app-desktop/runForTesting.sh
Executable file
68
packages/app-desktop/runForTesting.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup the sync parameters for user X and create a few folders and notes to
|
||||
# allow sharing. Also calls the API to create the test users and clear the data.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
||||
|
||||
if [ "$1" == "" ]; then
|
||||
echo "User number is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_NUM=$1
|
||||
COMMANDS=($(echo $2 | tr "," "\n"))
|
||||
PROFILE_DIR=~/.config/joplindev-desktop-$USER_NUM
|
||||
|
||||
CMD_FILE="$SCRIPT_DIR/runForSharingCommands-$USER_NUM.txt"
|
||||
rm -f "$CMD_FILE"
|
||||
touch "$CMD_FILE"
|
||||
|
||||
for CMD in "${COMMANDS[@]}"
|
||||
do
|
||||
if [[ $CMD == "createUsers" ]]; then
|
||||
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
|
||||
|
||||
elif [[ $CMD == "createData" ]]; then
|
||||
|
||||
echo 'mkbook "shared"' >> "$CMD_FILE"
|
||||
echo 'mkbook "other"' >> "$CMD_FILE"
|
||||
echo 'use "shared"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 1"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 2"' >> "$CMD_FILE"
|
||||
|
||||
elif [[ $CMD == "reset" ]]; then
|
||||
|
||||
USER_EMAIL="user$USER_NUM@example.com"
|
||||
rm -rf "$PROFILE_DIR"
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 10" >> "$CMD_FILE"
|
||||
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.10.password 123456" >> "$CMD_FILE"
|
||||
|
||||
elif [[ $CMD == "e2ee" ]]; then
|
||||
|
||||
echo "e2ee enable --password 111111" >> "$CMD_FILE"
|
||||
|
||||
else
|
||||
|
||||
echo "Unknown command: $CMD"
|
||||
exit 1
|
||||
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
|
||||
if [[ $COMMANDS != "" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
npm start -- --profile "$PROFILE_DIR"
|
@@ -20,13 +20,18 @@ function execCommand(command) {
|
||||
});
|
||||
}
|
||||
|
||||
function isDesktopAppTag(tagName) {
|
||||
if (!tagName) return false;
|
||||
return tagName[0] === 'v';
|
||||
}
|
||||
|
||||
module.exports = async function(params) {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
console.info('Checking if notarization should be done...');
|
||||
|
||||
if (!process.env.IS_CONTINUOUS_INTEGRATION || !process.env.GIT_TAG_NAME) {
|
||||
console.info(`Either not running in CI or not processing a tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
|
||||
if (!process.env.IS_CONTINUOUS_INTEGRATION || !isDesktopAppTag(process.env.GIT_TAG_NAME)) {
|
||||
console.info(`Either not running in CI or not processing a desktop app tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -141,8 +141,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097632
|
||||
versionName "2.0.1"
|
||||
versionCode 2097635
|
||||
versionName "2.0.4"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
@@ -486,13 +486,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 20.0.0;
|
||||
MARKETING_VERSION = 12.0.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -514,12 +514,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 20.0.0;
|
||||
MARKETING_VERSION = 12.0.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -659,14 +659,14 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 20.0.0;
|
||||
MARKETING_VERSION = 12.0.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
@@ -690,14 +690,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 65;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 20.0.0;
|
||||
MARKETING_VERSION = 12.0.2;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@@ -46,6 +46,8 @@ function isCannotSyncError(error: any): boolean {
|
||||
|
||||
export default class Synchronizer {
|
||||
|
||||
public static verboseMode: boolean = true;
|
||||
|
||||
private db_: any;
|
||||
private api_: any;
|
||||
private appType_: string;
|
||||
@@ -195,7 +197,11 @@ export default class Synchronizer {
|
||||
line.push(`(Remote ${s.join(', ')})`);
|
||||
}
|
||||
|
||||
this.logger().debug(line.join(': '));
|
||||
if (Synchronizer.verboseMode) {
|
||||
this.logger().info(line.join(': '));
|
||||
} else {
|
||||
this.logger().debug(line.join(': '));
|
||||
}
|
||||
|
||||
if (!this.progressReport_[action]) this.progressReport_[action] = 0;
|
||||
this.progressReport_[action] += actionCount;
|
||||
@@ -516,6 +522,40 @@ export default class Synchronizer {
|
||||
if (local.type_ == BaseModel.TYPE_RESOURCE && (action == 'createRemote' || action === 'updateRemote' || (action == 'itemConflict' && remote))) {
|
||||
const localState = await Resource.localState(local.id);
|
||||
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE) {
|
||||
// This condition normally shouldn't happen
|
||||
// because the normal cases are as follow:
|
||||
//
|
||||
// - User creates a resource locally - in that
|
||||
// case the fetch status is DONE, so we cannot
|
||||
// end up here.
|
||||
//
|
||||
// - User fetches a new resource metadata, but
|
||||
// not the blob - in that case fetch status is
|
||||
// IDLE. However in that case, we cannot end
|
||||
// up in this place either, because the action
|
||||
// cannot be createRemote (because the
|
||||
// resource has not been created locally) or
|
||||
// updateRemote (because a resouce cannot be
|
||||
// modified locally unless the blob is present
|
||||
// too).
|
||||
//
|
||||
// Possibly the only case we can end up here is
|
||||
// if a resource metadata has been downloaded,
|
||||
// but not the blob yet. Then the sync target is
|
||||
// switched to a different one. In that case, we
|
||||
// can have a fetch status IDLE, with an
|
||||
// "updateRemote" action, if the timestamp of
|
||||
// the server resource is before the timestamp
|
||||
// of the local resource.
|
||||
//
|
||||
// In that case we can't do much so we mark the
|
||||
// resource as "cannot sync". Otherwise it will
|
||||
// throw the error "Processing a path that has
|
||||
// already been done" on the next loop, and sync
|
||||
// will never finish because we'll always end up
|
||||
// here.
|
||||
this.logger().info(`Need to upload a resource, but blob is not present: ${path}`);
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, 'Trying to upload resource, but only metadata is present.');
|
||||
action = null;
|
||||
} else {
|
||||
try {
|
||||
|
@@ -1,26 +1,18 @@
|
||||
import { ModelType } from '../BaseModel';
|
||||
import { NoteEntity } from '../services/database/types';
|
||||
import { BaseItemEntity, NoteEntity } from '../services/database/types';
|
||||
import Setting from './Setting';
|
||||
import BaseModel from '../BaseModel';
|
||||
import time from '../time';
|
||||
import markdownUtils from '../markdownUtils';
|
||||
import { _ } from '../locale';
|
||||
|
||||
import Database from '../database';
|
||||
import ItemChange from './ItemChange';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
const JoplinError = require('../JoplinError.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
export interface BaseItemEntity {
|
||||
id?: string;
|
||||
encryption_applied?: boolean;
|
||||
is_shared?: number;
|
||||
share_id?: string;
|
||||
type_?: ModelType;
|
||||
}
|
||||
|
||||
export interface ItemsThatNeedDecryptionResult {
|
||||
hasMore: boolean;
|
||||
items: any[];
|
||||
@@ -404,7 +396,7 @@ export default class BaseItem extends BaseModel {
|
||||
|
||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||
|
||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || item.is_shared || item.share_id) {
|
||||
if (!Setting.value('encryption.enabled') || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
|
||||
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
||||
if (item.encryption_applied) throw new JoplinError('Item is encrypted but encryption is currently disabled', 'cannotSyncEncrypted');
|
||||
return serialized;
|
||||
|
@@ -127,7 +127,7 @@ export default class Note extends BaseItem {
|
||||
|
||||
static async linkedItemIdsByType(type: ModelType, body: string) {
|
||||
const items = await this.linkedItems(body);
|
||||
const output = [];
|
||||
const output: string[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
@@ -12,6 +12,7 @@ const { mime } = require('../mime-utils.js');
|
||||
const { filename, safeFilename } = require('../path-utils');
|
||||
const { FsDriverDummy } = require('../fs-driver-dummy.js');
|
||||
import JoplinError from '../JoplinError';
|
||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
|
||||
export default class Resource extends BaseItem {
|
||||
|
||||
@@ -192,10 +193,10 @@ export default class Resource extends BaseItem {
|
||||
// as it should be uploaded to the sync target. Note that this may be different from what is stored
|
||||
// in the database. In particular, the flag encryption_blob_encrypted might be 1 on the sync target
|
||||
// if the resource is encrypted, but will be 0 locally because the device has the decrypted resource.
|
||||
static async fullPathForSyncUpload(resource: ResourceEntity) {
|
||||
public static async fullPathForSyncUpload(resource: ResourceEntity) {
|
||||
const plainTextPath = this.fullPath(resource);
|
||||
|
||||
if (!Setting.value('encryption.enabled')) {
|
||||
if (!Setting.value('encryption.enabled') || !itemCanBeEncrypted(resource as any)) {
|
||||
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
||||
if (resource.encryption_blob_encrypted) throw new Error('Trying to access encrypted resource but encryption is currently disabled');
|
||||
return { path: plainTextPath, resource: resource };
|
||||
|
5
packages/lib/models/utils/itemCanBeEncrypted.ts
Normal file
5
packages/lib/models/utils/itemCanBeEncrypted.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BaseItemEntity } from '../../services/database/types';
|
||||
|
||||
export default function(resource: BaseItemEntity): boolean {
|
||||
return !resource.is_shared && !resource.share_id;
|
||||
}
|
@@ -1,3 +1,17 @@
|
||||
import { ModelType } from "../../BaseModel";
|
||||
|
||||
export interface BaseItemEntity {
|
||||
id?: string;
|
||||
encryption_applied?: boolean;
|
||||
is_shared?: number;
|
||||
share_id?: string;
|
||||
type_?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
||||
|
||||
/*
|
||||
@@ -29,8 +43,6 @@ export interface FolderEntity {
|
||||
"encryption_applied"?: number
|
||||
"parent_id"?: string
|
||||
"is_shared"?: number
|
||||
"is_linked_folder"?: number
|
||||
"source_folder_owner_id"?: string
|
||||
"share_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
@@ -98,7 +110,6 @@ export interface NoteEntity {
|
||||
"created_time"?: number
|
||||
"updated_time"?: number
|
||||
"is_conflict"?: number
|
||||
"conflict_original_id"?: string
|
||||
"latitude"?: number
|
||||
"longitude"?: number
|
||||
"altitude"?: number
|
||||
@@ -118,6 +129,7 @@ export interface NoteEntity {
|
||||
"markup_language"?: number
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"conflict_original_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NotesNormalizedEntity {
|
||||
@@ -227,6 +239,7 @@ export interface TagsWithNoteCountEntity {
|
||||
"created_time"?: number | null
|
||||
"updated_time"?: number | null
|
||||
"note_count"?: any | null
|
||||
"todo_completed_count"?: any | null
|
||||
"type_"?: number
|
||||
}
|
||||
export interface VersionEntity {
|
||||
|
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// If the userContentBaseUrl is an empty string, the baseUrl is returned instead.
|
||||
export default function(userId: string, baseUrl: string, userContentBaseUrl: string) {
|
||||
if (userContentBaseUrl) {
|
||||
if (userContentBaseUrl && baseUrl !== userContentBaseUrl) {
|
||||
if (!userId) throw new Error('User ID must be specified');
|
||||
const url = new URL(userContentBaseUrl);
|
||||
return `${url.protocol}//${userId.substr(0, 10).toLowerCase()}.${url.host}`;
|
||||
|
@@ -141,7 +141,7 @@ export default class ShareService {
|
||||
}
|
||||
|
||||
public shareUrl(userId: string, share: StateShare): string {
|
||||
return `${this.api().userContentBaseUrl(userId)}/shares/${share.id}`;
|
||||
return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
|
||||
}
|
||||
|
||||
public get shares() {
|
||||
|
@@ -8,9 +8,15 @@ import Resource from '../../models/Resource';
|
||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import { ResourceEntity } from '../database/types';
|
||||
import Synchronizer from '../../Synchronizer';
|
||||
|
||||
let insideBeforeEach = false;
|
||||
|
||||
function newResourceFetcher(synchronizer: Synchronizer) {
|
||||
return new ResourceFetcher(() => { return synchronizer.api(); });
|
||||
}
|
||||
|
||||
describe('Synchronizer.e2ee', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
@@ -223,7 +229,7 @@ describe('Synchronizer.e2ee', function() {
|
||||
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
|
||||
await encryptionService().loadMasterKeysFromSettings();
|
||||
|
||||
const fetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
||||
const fetcher = newResourceFetcher(synchronizer());
|
||||
fetcher.queueDownload_(resource1.id);
|
||||
await fetcher.waitForAllFinished();
|
||||
await decryptionWorker().start();
|
||||
@@ -287,7 +293,7 @@ describe('Synchronizer.e2ee', function() {
|
||||
expect(!!resource.encryption_applied).toBe(false);
|
||||
expect(!!resource.encryption_blob_encrypted).toBe(true);
|
||||
|
||||
const resourceFetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
||||
const resourceFetcher = newResourceFetcher(synchronizer());
|
||||
await resourceFetcher.start();
|
||||
await resourceFetcher.waitForAllFinished();
|
||||
|
||||
@@ -378,8 +384,15 @@ describe('Synchronizer.e2ee', function() {
|
||||
},
|
||||
]);
|
||||
|
||||
const note1 = await Note.loadByTitle('un');
|
||||
let note1 = await Note.loadByTitle('un');
|
||||
let note2 = await Note.loadByTitle('deux');
|
||||
await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
|
||||
await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
|
||||
note1 = await Note.loadByTitle('un');
|
||||
note2 = await Note.loadByTitle('deux');
|
||||
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
||||
const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(2);
|
||||
@@ -405,11 +418,37 @@ describe('Synchronizer.e2ee', function() {
|
||||
// The shared note should be decrypted
|
||||
const note2_2 = await Note.load(note2.id);
|
||||
expect(note2_2.title).toBe('deux');
|
||||
expect(note2_2.encryption_applied).toBe(0);
|
||||
expect(note2_2.is_shared).toBe(1);
|
||||
|
||||
// The resource linked to the shared note should also be decrypted
|
||||
const resource2: ResourceEntity = await Resource.load(resourceId2);
|
||||
expect(resource2.is_shared).toBe(1);
|
||||
expect(resource2.encryption_applied).toBe(0);
|
||||
|
||||
const fetcher = newResourceFetcher(synchronizer());
|
||||
await fetcher.start();
|
||||
await fetcher.waitForAllFinished();
|
||||
|
||||
// Because the resource is decrypted, the encrypted blob file should not
|
||||
// exist, but the plain text one should.
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource2, true))).toBe(false);
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource2))).toBe(true);
|
||||
|
||||
// The non-shared note should be encrypted
|
||||
const note1_2 = await Note.load(note1.id);
|
||||
expect(note1_2.title).toBe('');
|
||||
|
||||
// The linked resource should also be encrypted
|
||||
const resource1: ResourceEntity = await Resource.load(resourceId1);
|
||||
expect(resource1.is_shared).toBe(0);
|
||||
expect(resource1.encryption_applied).toBe(1);
|
||||
|
||||
// And the plain text blob should not be present. The encrypted one
|
||||
// shouldn't either because it can only be downloaded once the metadata
|
||||
// has been decrypted.
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
|
||||
}));
|
||||
|
||||
it('should not encrypt items that are shared by folder', (async () => {
|
||||
|
@@ -10,6 +10,7 @@ import Note from '../../models/Note';
|
||||
import Resource from '../../models/Resource';
|
||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import { ModelType } from '../../BaseModel';
|
||||
|
||||
let insideBeforeEach = false;
|
||||
|
||||
@@ -333,6 +334,13 @@ describe('Synchronizer.resources', function() {
|
||||
await Resource.setLocalState(resource.id, { fetch_status: Resource.FETCH_STATUS_DONE });
|
||||
await synchronizerStart();
|
||||
|
||||
// At first, the resource is marked as cannot sync, so even after
|
||||
// synchronisation, nothing should happen.
|
||||
expect((await remoteResources()).length).toBe(0);
|
||||
|
||||
// The user can retry the item, in which case sync should happen.
|
||||
await BaseItem.saveSyncEnabled(ModelType.Resource, resource.id);
|
||||
await synchronizerStart();
|
||||
expect((await remoteResources()).length).toBe(1);
|
||||
}));
|
||||
|
||||
|
2
packages/server/package-lock.json
generated
2
packages/server/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.13",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
@@ -38,6 +38,7 @@ export interface EnvVariables {
|
||||
|
||||
SIGNUP_ENABLED?: string;
|
||||
TERMS_ENABLED?: string;
|
||||
ACCOUNT_TYPES_ENABLED?: string;
|
||||
|
||||
ERROR_STACK_TRACES?: string;
|
||||
}
|
||||
@@ -152,6 +153,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
||||
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
|
||||
signupEnabled: env.SIGNUP_ENABLED === '1',
|
||||
termsEnabled: env.TERMS_ENABLED === '1',
|
||||
accountTypesEnabled: env.ACCOUNT_TYPES_ENABLED === '1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
@@ -309,13 +309,15 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
||||
item.jop_share_id = joplinItem.share_id || '';
|
||||
|
||||
delete joplinItem.id;
|
||||
delete joplinItem.parent_id;
|
||||
delete joplinItem.share_id;
|
||||
delete joplinItem.type_;
|
||||
delete joplinItem.encryption_applied;
|
||||
const joplinItemToSave = { ...joplinItem };
|
||||
|
||||
item.content = Buffer.from(JSON.stringify(joplinItem));
|
||||
delete joplinItemToSave.id;
|
||||
delete joplinItemToSave.parent_id;
|
||||
delete joplinItemToSave.share_id;
|
||||
delete joplinItemToSave.type_;
|
||||
delete joplinItemToSave.encryption_applied;
|
||||
|
||||
item.content = Buffer.from(JSON.stringify(joplinItemToSave));
|
||||
} else {
|
||||
item.content = buffer;
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import { formatBytes, MB } from '../utils/bytes';
|
||||
|
||||
export enum AccountType {
|
||||
Default = 0,
|
||||
Free = 1,
|
||||
Basic = 1,
|
||||
Pro = 2,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ interface AccountTypeProperties {
|
||||
max_item_size: number;
|
||||
}
|
||||
|
||||
interface AccountTypeSelectOptions {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function accountTypeProperties(accountType: AccountType): AccountTypeProperties {
|
||||
const types: AccountTypeProperties[] = [
|
||||
{
|
||||
@@ -26,7 +31,7 @@ export function accountTypeProperties(accountType: AccountType): AccountTypeProp
|
||||
max_item_size: 0,
|
||||
},
|
||||
{
|
||||
account_type: AccountType.Free,
|
||||
account_type: AccountType.Basic,
|
||||
can_share: 0,
|
||||
max_item_size: 10 * MB,
|
||||
},
|
||||
@@ -37,7 +42,26 @@ export function accountTypeProperties(accountType: AccountType): AccountTypeProp
|
||||
},
|
||||
];
|
||||
|
||||
return types.find(a => a.account_type === accountType);
|
||||
const type = types.find(a => a.account_type === accountType);
|
||||
if (!type) throw new Error(`Invalid account type: ${accountType}`);
|
||||
return type;
|
||||
}
|
||||
|
||||
export function accountTypeOptions(): AccountTypeSelectOptions[] {
|
||||
return [
|
||||
{
|
||||
value: AccountType.Default,
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
value: AccountType.Basic,
|
||||
label: 'Basic',
|
||||
},
|
||||
{
|
||||
value: AccountType.Pro,
|
||||
label: 'Pro',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default class UserModel extends BaseModel<User> {
|
||||
@@ -68,6 +92,8 @@ export default class UserModel extends BaseModel<User> {
|
||||
if ('full_name' in object) user.full_name = object.full_name;
|
||||
if ('max_item_size' in object) user.max_item_size = object.max_item_size;
|
||||
if ('can_share' in object) user.can_share = object.can_share;
|
||||
if ('account_type' in object) user.account_type = object.account_type;
|
||||
if ('must_set_password' in object) user.must_set_password = object.must_set_password;
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -94,7 +120,10 @@ export default class UserModel extends BaseModel<User> {
|
||||
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
||||
if (!user.is_admin && 'is_admin' in resource) throw new ErrorForbidden('non-admin user cannot make themselves an admin');
|
||||
if (user.is_admin && user.id === resource.id && 'is_admin' in resource && !resource.is_admin) throw new ErrorForbidden('admin user cannot make themselves a non-admin');
|
||||
if (!user.is_admin && resource.max_item_size !== previousResource.max_item_size) throw new ErrorForbidden('non-admin user cannot change max_item_size');
|
||||
if ('max_item_size' in resource && !user.is_admin && resource.max_item_size !== previousResource.max_item_size) throw new ErrorForbidden('non-admin user cannot change max_item_size');
|
||||
if ('can_share' in resource && !user.is_admin && resource.can_share !== previousResource.can_share) throw new ErrorForbidden('non-admin user cannot change can_share');
|
||||
if ('account_type' in resource && !user.is_admin && resource.account_type !== previousResource.account_type) throw new ErrorForbidden('non-admin user cannot change account_type');
|
||||
if ('must_set_password' in resource && !user.is_admin && resource.must_set_password !== previousResource.must_set_password) throw new ErrorForbidden('non-admin user cannot change must_set_password');
|
||||
}
|
||||
|
||||
if (action === AclAction.Delete) {
|
||||
@@ -108,17 +137,17 @@ export default class UserModel extends BaseModel<User> {
|
||||
}
|
||||
|
||||
public async checkMaxItemSizeLimit(user: User, buffer: Buffer, item: Item, joplinItem: any) {
|
||||
const itemTitle = joplinItem ? joplinItem.title || '' : '';
|
||||
const isNote = joplinItem && joplinItem.type_ === ModelType.Note;
|
||||
|
||||
// If the item is encrypted, we apply a multipler because encrypted
|
||||
// items can be much larger (seems to be up to twice the size but for
|
||||
// safety let's go with 2.2).
|
||||
const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1);
|
||||
if (maxSize && buffer.byteLength > maxSize) {
|
||||
const itemTitle = joplinItem ? joplinItem.title || '' : '';
|
||||
const isNote = joplinItem && joplinItem.type_ === ModelType.Note;
|
||||
|
||||
throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)',
|
||||
isNote ? _('note') : _('attachment'),
|
||||
itemTitle ? itemTitle : name,
|
||||
itemTitle ? itemTitle : item.name,
|
||||
formatBytes(user.max_item_size)
|
||||
));
|
||||
}
|
||||
@@ -147,7 +176,7 @@ export default class UserModel extends BaseModel<User> {
|
||||
|
||||
if (options.isNew) {
|
||||
if (!user.email) throw new ErrorUnprocessableEntity('email must be set');
|
||||
if (!user.password) throw new ErrorUnprocessableEntity('password must be set');
|
||||
if (!user.password && !user.must_set_password) throw new ErrorUnprocessableEntity('password must be set');
|
||||
} else {
|
||||
if ('email' in user && !user.email) throw new ErrorUnprocessableEntity('email must be set');
|
||||
if ('password' in user && !user.password) throw new ErrorUnprocessableEntity('password must be set');
|
||||
@@ -196,6 +225,17 @@ export default class UserModel extends BaseModel<User> {
|
||||
await this.save({ id: user.id, email_confirmed: 1 });
|
||||
}
|
||||
|
||||
// public async saveWithAccountType(accountType:AccountType, user: User, options: SaveOptions = {}): Promise<User> {
|
||||
// if (accountType !== AccountType.Default) {
|
||||
// user = {
|
||||
// ...user,
|
||||
// ...accountTypeProperties(accountType),
|
||||
// };
|
||||
// }
|
||||
|
||||
// return this.save(user, options);
|
||||
// }
|
||||
|
||||
// Note that when the "password" property is provided, it is going to be
|
||||
// hashed automatically. It means that it is not safe to do:
|
||||
//
|
||||
|
@@ -41,7 +41,7 @@ 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.account_type).toBe(AccountType.Basic);
|
||||
expect(user.email_confirmed).toBe(0);
|
||||
expect(user.can_share).toBe(0);
|
||||
expect(user.max_item_size).toBe(10 * MB);
|
||||
|
@@ -44,7 +44,7 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
|
||||
const password = checkPassword(formUser, true);
|
||||
|
||||
const user = await ctx.models.user().save({
|
||||
...accountTypeProperties(AccountType.Free),
|
||||
...accountTypeProperties(AccountType.Basic),
|
||||
email: formUser.email,
|
||||
full_name: formUser.full_name,
|
||||
password,
|
||||
|
@@ -85,6 +85,7 @@ describe('index_users', function() {
|
||||
expect(!!newUser.is_admin).toBe(false);
|
||||
expect(!!newUser.email).toBe(true);
|
||||
expect(newUser.max_item_size).toBe(0);
|
||||
expect(newUser.must_set_password).toBe(0);
|
||||
|
||||
const userModel = models().user();
|
||||
const userFromModel: User = await userModel.load(newUser.id);
|
||||
@@ -93,6 +94,18 @@ describe('index_users', function() {
|
||||
expect(userFromModel.password === '123456').toBe(false); // Password has been hashed
|
||||
});
|
||||
|
||||
test('should ask user to set password if not set on creation', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
await postUser(session.id, 'test@example.com', '', {
|
||||
max_item_size: '',
|
||||
});
|
||||
const newUser = await models().user().loadByEmail('test@example.com');
|
||||
|
||||
expect(newUser.must_set_password).toBe(1);
|
||||
expect(!!newUser.password).toBe(true);
|
||||
});
|
||||
|
||||
test('new user should be able to login', async function() {
|
||||
const { session } = await createUserAndSession(1, true);
|
||||
|
||||
@@ -123,7 +136,7 @@ describe('index_users', function() {
|
||||
});
|
||||
|
||||
test('should change user properties', async function() {
|
||||
const { user, session } = await createUserAndSession(1, true);
|
||||
const { user, session } = await createUserAndSession(1, false);
|
||||
|
||||
const userModel = models().user();
|
||||
|
||||
@@ -298,7 +311,11 @@ describe('index_users', function() {
|
||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change max_item_size
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
|
||||
|
||||
// non-admin cannot change can_share
|
||||
await models().user().save({ id: user1.id, can_share: 0 });
|
||||
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share: 1 }), ErrorForbidden.httpCode);
|
||||
});
|
||||
|
||||
|
||||
|
@@ -11,6 +11,8 @@ import defaultView from '../../utils/defaultView';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { NotificationKey } from '../../models/NotificationModel';
|
||||
import { formatBytes } from '../../utils/bytes';
|
||||
import { accountTypeOptions, accountTypeProperties } from '../../models/UserModel';
|
||||
import uuidgen from '../../utils/uuidgen';
|
||||
|
||||
interface CheckPasswordInput {
|
||||
password: string;
|
||||
@@ -29,25 +31,39 @@ export function checkPassword(fields: CheckPasswordInput, required: boolean): st
|
||||
}
|
||||
|
||||
function makeUser(isNew: boolean, fields: any): User {
|
||||
const user: User = {};
|
||||
let user: User = {};
|
||||
|
||||
if ('email' in fields) user.email = fields.email;
|
||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||
if ('max_item_size' in fields) user.max_item_size = fields.max_item_size || 0;
|
||||
user.can_share = fields.can_share ? 1 : 0;
|
||||
if ('can_share' in fields) user.can_share = fields.can_share ? 1 : 0;
|
||||
|
||||
if ('account_type' in fields) {
|
||||
user.account_type = Number(fields.account_type);
|
||||
user = {
|
||||
...user,
|
||||
...accountTypeProperties(user.account_type),
|
||||
};
|
||||
}
|
||||
|
||||
const password = checkPassword(fields, false);
|
||||
if (password) user.password = password;
|
||||
|
||||
if (!isNew) user.id = fields.id;
|
||||
|
||||
if (isNew) {
|
||||
user.must_set_password = user.password ? 0 : 1;
|
||||
user.password = user.password ? user.password : uuidgen();
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function defaultUser(): User {
|
||||
return {
|
||||
can_share: 1,
|
||||
max_item_size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +123,14 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
|
||||
view.content.postUrl = postUrl;
|
||||
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
|
||||
|
||||
if (config().accountTypesEnabled) {
|
||||
view.content.showAccountTypes = true;
|
||||
view.content.accountTypes = accountTypeOptions().map((o: any) => {
|
||||
o.selected = user.account_type === o.value;
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
return view;
|
||||
});
|
||||
|
||||
|
@@ -80,6 +80,7 @@ export interface Config {
|
||||
userContentBaseUrl: string;
|
||||
signupEnabled: boolean;
|
||||
termsEnabled: boolean;
|
||||
accountTypesEnabled: boolean;
|
||||
showErrorStackTraces: boolean;
|
||||
database: DatabaseConfig;
|
||||
mailer: MailerConfig;
|
||||
|
@@ -15,6 +15,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{{#global.owner.is_admin}}
|
||||
{{#showAccountTypes}}
|
||||
<div class="field">
|
||||
<label class="label">Account type</label>
|
||||
<div class="select">
|
||||
<select name="account_type">
|
||||
{{#accountTypes}}
|
||||
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
|
||||
{{/accountTypes}}
|
||||
</select>
|
||||
</div>
|
||||
<p class="help">If the account type is anything other than Default, the account-specific properties will be applied.</p>
|
||||
</div>
|
||||
{{/showAccountTypes}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Max item size</label>
|
||||
<div class="control">
|
||||
@@ -39,7 +53,10 @@
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password2" autocomplete="new-password"/>
|
||||
</div>
|
||||
</div>
|
||||
{{#global.owner.is_admin}}
|
||||
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
|
||||
{{/global.owner.is_admin}}
|
||||
</div>
|
||||
<div class="control">
|
||||
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
|
||||
{{#showDeleteButton}}
|
||||
|
@@ -47,7 +47,12 @@ async function main() {
|
||||
|
||||
const targetFile = `${rootDir}/packages/lib/services/database/types.ts`;
|
||||
console.info(`Writing type definitions to ${targetFile}...`);
|
||||
await fs.writeFile(targetFile, `${header}\n\n${tsString}`, 'utf8');
|
||||
|
||||
const existingContent = (await fs.pathExists(targetFile)) ? await fs.readFile(targetFile, 'utf8') : '';
|
||||
const splitted = existingContent.split('// AUTO-GENERATED BY');
|
||||
const staticContent = splitted[0];
|
||||
|
||||
await fs.writeFile(targetFile, `${staticContent}\n\n${header}\n\n${tsString}`, 'utf8');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
@@ -351,6 +351,7 @@ async function main() {
|
||||
|
||||
let publishFormat = 'full';
|
||||
if (['android', 'ios'].indexOf(platform) >= 0) publishFormat = 'simple';
|
||||
if (argv.publishFormat) publishFormat = argv.publishFormat;
|
||||
let changelog = createChangeLog(filteredLogs, { publishFormat: publishFormat });
|
||||
|
||||
const changelogFixes = [];
|
||||
|
@@ -58,12 +58,12 @@ async function updatePluginGeneratorTemplateVersion(manifestPath, majorMinorVers
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, '\t'));
|
||||
}
|
||||
|
||||
// Need this hack to transform 1.x.x into 10.x.x due to some mistake
|
||||
// Need this hack to transform x.y.z into 1x.y.z due to some mistake
|
||||
// on one of the release and the App Store won't allow decreasing
|
||||
// the major version number.
|
||||
function iosVersionHack(majorMinorVersion) {
|
||||
const p = majorMinorVersion.split('.');
|
||||
p[0] = `${p[0]}0`;
|
||||
p[0] = `1${p[0]}`;
|
||||
return p.join('.');
|
||||
}
|
||||
|
||||
|
@@ -65,7 +65,7 @@ async function insertChangelog(tag: string, changelogPath: string, changelog: st
|
||||
}
|
||||
|
||||
export async function completeReleaseWithChangelog(changelogPath: string, newVersion: string, newTag: string, appName: string, isPreRelease: boolean) {
|
||||
const changelog = (await execCommand2(`node ${rootDir}/packages/tools/git-changelog ${newTag}`, { })).trim();
|
||||
const changelog = (await execCommand2(`node ${rootDir}/packages/tools/git-changelog ${newTag} --publish-format full`, { })).trim();
|
||||
|
||||
const newChangelog = await insertChangelog(newTag, changelogPath, changelog, isPreRelease);
|
||||
|
||||
|
@@ -1 +1,22 @@
|
||||
# Joplin Android app changelog
|
||||
|
||||
## [android-v2.0.4](https://github.com/laurent22/joplin/releases/tag/android-v2.0.4) - 2021-06-16T12:15:56Z
|
||||
|
||||
- Improved: Prevent sync process from being stuck when the download state of a resource is invalid (5c6fd93)
|
||||
|
||||
## [android-v2.0.3](https://github.com/laurent22/joplin/releases/tag/android-v2.0.3) (Pre-release) - 2021-06-16T09:48:58Z
|
||||
|
||||
- Improved: Verbose mode for synchronizer (4bbb3d1)
|
||||
|
||||
## [android-v2.0.2](https://github.com/laurent22/joplin/releases/tag/android-v2.0.2) - 2021-06-15T20:03:21Z
|
||||
|
||||
- Improved: Conflict notes will now populate a new field with the ID of the conflict note. (#5049 by [@Ahmad45123](https://github.com/Ahmad45123))
|
||||
- Improved: Filter out form elements from note body to prevent potential XSS (thanks to Dmytro Vdovychinskiy for the PoC) (feaecf7)
|
||||
- Improved: Focus note editor where tapped instead of scrolling to the end (#4998) (#4216 by Roman Musin)
|
||||
- Improved: Improve search with Asian scripts (#5018) (#4613 by [@mablin7](https://github.com/mablin7))
|
||||
- Fixed: Fixed and improved alarm notifications (#4984) (#4912 by Roman Musin)
|
||||
- Fixed: Fixed opening URLs that contain non-alphabetical characters (#4494)
|
||||
- Fixed: Fixed user content URLs when sharing note via Joplin Server (2cf7067)
|
||||
- Fixed: Inline Katex gets broken when editing in Rich Text editor (#5052) (#5025 by [@Subhra264](https://github.com/Subhra264))
|
||||
- Fixed: Items are filtered in the API search (#5017) (#5007 by [@JackGruber](https://github.com/JackGruber))
|
||||
- Fixed: Wrong field removed in API search (#5066 by [@JackGruber](https://github.com/JackGruber))
|
||||
|
@@ -1,6 +1,17 @@
|
||||
# Joplin Server Changelog
|
||||
|
||||
## [server-v2.0.10](https://github.com/laurent22/joplin/releases/tag/server-v2.0.10) - 2021-06-15T11:27:29Z
|
||||
## [server-v2.0.13](https://github.com/laurent22/joplin/releases/tag/server-v2.0.13) - 2021-06-16T14:28:20Z
|
||||
|
||||
- Improved: Allow creating a new user with no password, which must be set via email confirmation (1896549)
|
||||
- Improved: Allow creating a user with a specific account type from admin UI (ecd1602)
|
||||
- Fixed: Fixed error message when item is over the limit (ea65313)
|
||||
- Fixed: Fixed issue with user not being able to modify own profile (3c18190)
|
||||
|
||||
## [server-v2.0.12](https://github.com/laurent22/joplin/releases/tag/server-v2.0.12) - 2021-06-15T16:24:42Z
|
||||
|
||||
- Fixed: Fixed handling of user content URL (31121c8)
|
||||
|
||||
## [server-v2.0.11](https://github.com/laurent22/joplin/releases/tag/server-v2.0.11) - 2021-06-15T11:41:41Z
|
||||
|
||||
- New: Add navbar on login and sign up page (7a3a208)
|
||||
- New: Added option to enable or disable stack traces (5614eb9)
|
||||
|
Reference in New Issue
Block a user