1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-27 20:29:45 +02:00

Compare commits

...

27 Commits

Author SHA1 Message Date
Laurent Cozic
8c56cf98be Server v2.0.13 2021-06-16 15:28:41 +01:00
Laurent Cozic
18965494d9 Server: Allow creating a new user with no password, which must be set via email confirmation 2021-06-16 15:24:15 +01:00
Laurent Cozic
ecd1602658 Server: Allow creating a user with a specific account type from admin UI 2021-06-16 15:02:26 +01:00
Laurent Cozic
3c181906c2 Server: Fixed issue with user not being able to modify own profile 2021-06-16 14:34:58 +01:00
Laurent Cozic
9e1e144311 Android 2.0.4 2021-06-16 13:22:51 +01:00
Laurent Cozic
757c125bd3 Android release v2.0.4 2021-06-16 13:15:09 +01:00
Laurent Cozic
2867b66cf1 Tools: Fixed tests 2021-06-16 13:10:42 +01:00
Laurent Cozic
5c6fd93753 All: Prevent sync process from being stuck when the download state of a resource is invalid 2021-06-16 13:03:10 +01:00
Laurent Cozic
ea65313bdb Server: Fixed error message when item is over the limit 2021-06-16 11:07:21 +01:00
Laurent Cozic
1711f7ec88 Android 2.0.3 2021-06-16 10:49:12 +01:00
Laurent Cozic
e0b5ef6630 Android release v2.0.3 2021-06-16 10:48:10 +01:00
Laurent Cozic
4bbb3d1d58 Android: Verbose mode for synchronizer 2021-06-16 10:43:39 +01:00
Laurent Cozic
fd769945b1 ios-v12.0.2 2021-06-16 09:26:21 +01:00
Laurent Cozic
6e91d2784f Tools: Fixed iOS versions 2021-06-16 09:26:10 +01:00
Laurent Cozic
881b2f17b1 ios-v20.0.1 2021-06-16 09:06:37 +01:00
Laurent Cozic
e83cc58ea6 Tools: Fix iOS version number 2021-06-16 09:04:41 +01:00
Laurent Cozic
77def9f782 Tools: Publish full changelog for Android app 2021-06-15 21:08:55 +01:00
Laurent Cozic
b23cc5d30a Android 2.0.2 2021-06-15 21:08:28 +01:00
Laurent Cozic
d8119bcf07 Android release v2.0.2 2021-06-15 21:02:32 +01:00
Laurent Cozic
8bce259dc9 Desktop release v2.0.10 2021-06-15 20:56:38 +01:00
Laurent Cozic
8a00eef901 Server v2.0.12 2021-06-15 17:24:56 +01:00
Laurent Cozic
31121c86d5 Server: Fixed handling of user content URL 2021-06-15 17:24:04 +01:00
Laurent Cozic
a4a156c7a5 Desktop: Fixes #5080: Ensure resources are decrypted when sharing a notebook with Joplin Server 2021-06-15 17:17:12 +01:00
Laurent Cozic
c5b0529968 Cli: Allow setting up E2EE without having to confirm the password 2021-06-15 17:15:00 +01:00
Laurent Cozic
ba322b1f9b Tools: Only notarize macOS app when building a desktop app tag 2021-06-15 16:05:26 +01:00
Laurent Cozic
6f27eae7dd Server v2.0.11 2021-06-15 12:41:59 +01:00
Laurent Cozic
85cc08c0d4 typo 2021-06-15 12:41:15 +01:00
38 changed files with 402 additions and 124 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.0.9",
"version": "2.0.10",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -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,

View File

@@ -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"

View 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"

View File

@@ -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;
}

View File

@@ -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"
}

View File

@@ -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)";

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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];

View File

@@ -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 };

View File

@@ -0,0 +1,5 @@
import { BaseItemEntity } from '../../services/database/types';
export default function(resource: BaseItemEntity): boolean {
return !resource.is_shared && !resource.share_id;
}

View File

@@ -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 {

View File

@@ -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}`;

View File

@@ -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() {

View File

@@ -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 () => {

View File

@@ -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);
}));

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.0.10",
"version": "2.0.13",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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:
//

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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;
});

View File

@@ -80,6 +80,7 @@ export interface Config {
userContentBaseUrl: string;
signupEnabled: boolean;
termsEnabled: boolean;
accountTypesEnabled: boolean;
showErrorStackTraces: boolean;
database: DatabaseConfig;
mailer: MailerConfig;

View File

@@ -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}}

View File

@@ -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) => {

View File

@@ -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 = [];

View File

@@ -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('.');
}

View File

@@ -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);

View File

@@ -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))

View File

@@ -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)