1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-17 00:33:59 +02:00

Compare commits

...

10 Commits

Author SHA1 Message Date
Laurent Cozic
93fe919300 Optimisation 2021-09-25 17:29:35 +01:00
Laurent Cozic
a756db2ef4 fix 2021-09-25 15:25:34 +01:00
Laurent Cozic
843b52000e allow counting number of shared items 2021-09-24 18:07:23 +01:00
Laurent Cozic
e8e8ea3780 typo 2021-09-24 15:14:00 +01:00
Laurent Cozic
3c13c8d080 Tools: Allow tagging a server release as "latest" 2021-09-23 17:13:54 +01:00
Laurent Cozic
97bfd5ef04 Tools: Add test unit for building Docker image 2021-09-23 16:48:26 +01:00
Laurent Cozic
e3fd34e5d6 Server: Security: Implement clickjacking defense 2021-09-23 15:56:40 +01:00
Laurent Cozic
f144daed96 Desktop, Cli: Allow importing certain corrupted ENEX files 2021-09-23 15:35:49 +01:00
Laurent Cozic
add9d884e6 Doc: Improved pre-release doc and link to it from Donate section 2021-09-23 14:18:31 +01:00
Laurent Cozic
62f81b4315 Chore: Converts ENEX import file to TypeScript 2021-09-23 13:16:22 +01:00
29 changed files with 8580 additions and 192 deletions

View File

@@ -1000,6 +1000,9 @@ packages/lib/import-enex-md-gen.js.map
packages/lib/import-enex-md-gen.test.d.ts
packages/lib/import-enex-md-gen.test.js
packages/lib/import-enex-md-gen.test.js.map
packages/lib/import-enex.d.ts
packages/lib/import-enex.js
packages/lib/import-enex.js.map
packages/lib/locale.d.ts
packages/lib/locale.js
packages/lib/locale.js.map
@@ -1813,6 +1816,9 @@ packages/renderer/utils.js.map
packages/tools/buildServerDocker.d.ts
packages/tools/buildServerDocker.js
packages/tools/buildServerDocker.js.map
packages/tools/buildServerDocker.test.d.ts
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.test.js.map
packages/tools/convertThemesToCss.d.ts
packages/tools/convertThemesToCss.js
packages/tools/convertThemesToCss.js.map
@@ -1846,6 +1852,9 @@ packages/tools/release-server.js.map
packages/tools/setupNewRelease.d.ts
packages/tools/setupNewRelease.js
packages/tools/setupNewRelease.js.map
packages/tools/tagServerLatest.d.ts
packages/tools/tagServerLatest.js
packages/tools/tagServerLatest.js.map
packages/tools/tool-utils.d.ts
packages/tools/tool-utils.js
packages/tools/tool-utils.js.map

9
.gitignore vendored
View File

@@ -985,6 +985,9 @@ packages/lib/import-enex-md-gen.js.map
packages/lib/import-enex-md-gen.test.d.ts
packages/lib/import-enex-md-gen.test.js
packages/lib/import-enex-md-gen.test.js.map
packages/lib/import-enex.d.ts
packages/lib/import-enex.js
packages/lib/import-enex.js.map
packages/lib/locale.d.ts
packages/lib/locale.js
packages/lib/locale.js.map
@@ -1798,6 +1801,9 @@ packages/renderer/utils.js.map
packages/tools/buildServerDocker.d.ts
packages/tools/buildServerDocker.js
packages/tools/buildServerDocker.js.map
packages/tools/buildServerDocker.test.d.ts
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.test.js.map
packages/tools/convertThemesToCss.d.ts
packages/tools/convertThemesToCss.js
packages/tools/convertThemesToCss.js.map
@@ -1831,6 +1837,9 @@ packages/tools/release-server.js.map
packages/tools/setupNewRelease.d.ts
packages/tools/setupNewRelease.js
packages/tools/setupNewRelease.js.map
packages/tools/tagServerLatest.d.ts
packages/tools/tagServerLatest.js
packages/tools/tagServerLatest.js.map
packages/tools/tool-utils.d.ts
packages/tools/tool-utils.js
packages/tools/tool-utils.js.map

View File

@@ -37,6 +37,7 @@
"releaseIOS": "node packages/tools/release-ios.js",
"releasePluginGenerator": "node packages/tools/release-plugin-generator.js",
"releaseServer": "node packages/tools/release-server.js",
"tagServerLatest": "node packages/tools/tagServerLatest.js",
"buildServerDocker": "node packages/tools/buildServerDocker.js",
"setupNewRelease": "node ./packages/tools/setupNewRelease",
"test-ci": "lerna run test-ci --stream",

View File

@@ -4,7 +4,7 @@ const Folder = require('@joplin/lib/models/Folder').default;
const { themeStyle } = require('@joplin/lib/theme');
const { _ } = require('@joplin/lib/locale');
const { filename, basename } = require('@joplin/lib/path-utils');
const { importEnex } = require('@joplin/lib/import-enex');
const importEnex = require('@joplin/lib/import-enex').default;
class ImportScreenComponent extends React.Component {
UNSAFE_componentWillMount() {

View File

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

View File

@@ -6,7 +6,7 @@ const os = require('os');
const { filename } = require('./path-utils');
import { setupDatabaseAndSynchronizer, switchClient, expectNotThrow, supportDir } from './testing/test-utils';
const { enexXmlToMd } = require('./import-enex-md-gen.js');
const { importEnex } = require('./import-enex');
import importEnex from './import-enex';
import Note from './models/Note';
import Tag from './models/Tag';
import Resource from './models/Resource';

View File

@@ -1,26 +1,27 @@
const uuid = require('./uuid').default;
import uuid from './uuid';
import BaseModel from './BaseModel';
import Note from './models/Note';
import Tag from './models/Tag';
import Resource from './models/Resource';
import Setting from './models/Setting';
import time from './time';
import shim from './shim';
import { NoteEntity } from './services/database/types';
import { enexXmlToMd } from './import-enex-md-gen';
import { MarkupToHtml } from '@joplin/renderer';
const moment = require('moment');
const BaseModel = require('./BaseModel').default;
const Note = require('./models/Note').default;
const Tag = require('./models/Tag').default;
const Resource = require('./models/Resource').default;
const Setting = require('./models/Setting').default;
const { MarkupToHtml } = require('@joplin/renderer');
const { wrapError } = require('./errorUtils');
const { enexXmlToMd } = require('./import-enex-md-gen.js');
const { enexXmlToHtml } = require('./import-enex-html-gen.js');
const time = require('./time').default;
const Levenshtein = require('levenshtein');
const md5 = require('md5');
const { Base64Decode } = require('base64-stream');
const md5File = require('md5-file');
const shim = require('./shim').default;
const { mime } = require('./mime-utils');
// const Promise = require('promise');
const fs = require('fs-extra');
function dateToTimestamp(s, defaultValue = null) {
function dateToTimestamp(s: string, defaultValue: number = null): number {
// Most dates seem to be in this format
let m = moment(s, 'YYYYMMDDTHHmmssZ');
@@ -36,12 +37,12 @@ function dateToTimestamp(s, defaultValue = null) {
return m.toDate().getTime();
}
function extractRecognitionObjId(recognitionXml) {
function extractRecognitionObjId(recognitionXml: string) {
const r = recognitionXml.match(/objID="(.*?)"/);
return r && r.length >= 2 ? r[1] : null;
}
async function decodeBase64File(sourceFilePath, destFilePath) {
async function decodeBase64File(sourceFilePath: string, destFilePath: string) {
// When something goes wrong with streams you can get an error "EBADF, Bad file descriptor"
// with no strack trace to tell where the error happened.
@@ -73,17 +74,17 @@ async function decodeBase64File(sourceFilePath, destFilePath) {
destStream.on('finish', () => {
fs.fdatasyncSync(destFile);
fs.closeSync(destFile);
resolve();
resolve(null);
});
sourceStream.on('error', (error) => reject(error));
destStream.on('error', (error) => reject(error));
sourceStream.on('error', (error: any) => reject(error));
destStream.on('error', (error: any) => reject(error));
});
}
async function md5FileAsync(filePath) {
async function md5FileAsync(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
md5File(filePath, (error, hash) => {
md5File(filePath, (error: any, hash: string) => {
if (error) {
reject(error);
return;
@@ -94,24 +95,24 @@ async function md5FileAsync(filePath) {
});
}
function removeUndefinedProperties(note) {
const output = {};
function removeUndefinedProperties(note: NoteEntity) {
const output: any = {};
for (const n in note) {
if (!note.hasOwnProperty(n)) continue;
const v = note[n];
const v = (note as any)[n];
if (v === undefined || v === null) continue;
output[n] = v;
}
return output;
}
function levenshteinPercent(s1, s2) {
function levenshteinPercent(s1: string, s2: string) {
const l = new Levenshtein(s1, s2);
if (!s1.length || !s2.length) return 1;
return Math.abs(l.distance / s1.length);
}
async function fuzzyMatch(note) {
async function fuzzyMatch(note: ExtractedNote) {
if (note.created_time < time.unixMs() - 1000 * 60 * 60 * 24 * 360) {
const notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ? AND title = ?', [note.created_time, note.title]);
return notes.length !== 1 ? null : notes[0];
@@ -137,9 +138,30 @@ async function fuzzyMatch(note) {
return null;
}
interface ExtractedResource {
hasData?: boolean;
id?: string;
size?: number;
dataFilePath?: string;
dataEncoding?: string;
data?: string;
filename?: string;
sourceUrl?: string;
mime?: string;
title?: string;
}
interface ExtractedNote extends NoteEntity {
resources?: ExtractedResource[];
tags?: string[];
title?: string;
bodyXml?: string;
// is_todo?: boolean;
}
// At this point we have the resource has it's been parsed from the XML, but additional
// processing needs to be done to get the final resource file, its size, MD5, etc.
async function processNoteResource(resource) {
async function processNoteResource(resource: ExtractedResource) {
if (!resource.hasData) {
// Some resources have no data, go figure, so we need a special case for this.
resource.id = md5(Date.now() + Math.random());
@@ -175,7 +197,7 @@ async function processNoteResource(resource) {
return resource;
}
async function saveNoteResources(note) {
async function saveNoteResources(note: ExtractedNote) {
let resourcesCreated = 0;
for (let i = 0; i < note.resources.length; i++) {
const resource = note.resources[i];
@@ -198,7 +220,7 @@ async function saveNoteResources(note) {
return resourcesCreated;
}
async function saveNoteTags(note) {
async function saveNoteTags(note: ExtractedNote) {
let notesTagged = 0;
for (let i = 0; i < note.tags.length; i++) {
const tagTitle = note.tags[i];
@@ -213,12 +235,19 @@ async function saveNoteTags(note) {
return notesTagged;
}
async function saveNoteToStorage(note, importOptions) {
interface ImportOptions {
fuzzyMatching?: boolean;
onProgress?: Function;
onError?: Function;
outputFormat?: string;
}
async function saveNoteToStorage(note: ExtractedNote, importOptions: ImportOptions) {
importOptions = Object.assign({}, {
fuzzyMatching: false,
}, importOptions);
note = Note.filter(note);
note = Note.filter(note as any);
const existingNote = importOptions.fuzzyMatching ? await fuzzyMatch(note) : null;
@@ -230,7 +259,7 @@ async function saveNoteToStorage(note, importOptions) {
notesTagged: 0,
};
const resourcesCreated = await saveNoteResources(note, importOptions);
const resourcesCreated = await saveNoteResources(note);
result.resourcesCreated += resourcesCreated;
const notesTagged = await saveNoteTags(note);
@@ -262,16 +291,50 @@ async function saveNoteToStorage(note, importOptions) {
return result;
}
function importEnex(parentFolderId, filePath, importOptions = null) {
interface Node {
name: string;
attributes: Record<string, any>;
}
interface NoteResourceRecognition {
objID?: string;
}
const preProcessFile = async (filePath: string): Promise<string> => {
const content: string = await shim.fsDriver().readFile(filePath, 'utf8');
// The note content in an ENEX file is wrapped in a CDATA block so it means
// that any "]]>" inside the note must be somehow escaped, or else the CDATA
// block would be closed at the wrong point.
//
// The problem is that Evernote appears to encode "]]>" as "]]<![CDATA[>]]>"
// instead of the more sensible "]]&gt;", or perhaps they have nothing in
// place to properly escape data imported from their web clipper. In any
// case it results in invalid XML that Evernote cannot even import back.
//
// Handling that invalid XML with SAX would also be very tricky, so instead
// we add a pre-processing step that converts this tags to just "&gt;". It
// should be safe to do so because such content can only be within the body
// of a note - and ">" or "&gt;" is equivalent.
//
// Ref: https://discourse.joplinapp.org/t/20470/4
const newContent = content.replace(/<!\[CDATA\[>\]\]>/g, '&gt;');
if (content === newContent) return filePath;
const newFilePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.enex`;
await shim.fsDriver().writeFile(newFilePath, newContent, 'utf8');
return newFilePath;
};
export default async function importEnex(parentFolderId: string, filePath: string, importOptions: ImportOptions = null) {
if (!importOptions) importOptions = {};
if (!('fuzzyMatching' in importOptions)) importOptions.fuzzyMatching = false;
if (!('onProgress' in importOptions)) importOptions.onProgress = function() {};
if (!('onError' in importOptions)) importOptions.onError = function() {};
function handleSaxStreamEvent(fn) {
return function(...args) {
function handleSaxStreamEvent(fn: Function) {
return function(...args: any[]) {
// Pass the parser to the wrapped function for debugging purposes
if (this._parser) fn._parser = this._parser;
if (this._parser) (fn as any)._parser = this._parser;
try {
fn.call(this, ...args);
@@ -285,6 +348,9 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
};
}
const fileToProcess = await preProcessFile(filePath);
const needToDeleteFileToProcess = fileToProcess !== filePath;
return new Promise((resolve) => {
const progressState = {
loaded: 0,
@@ -295,22 +361,22 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
notesTagged: 0,
};
const stream = fs.createReadStream(filePath);
const stream = fs.createReadStream(fileToProcess);
const options = {};
const strict = true;
const saxStream = require('@joplin/fork-sax').createStream(strict, options);
const nodes = []; // LIFO list of nodes so that we know in which node we are in the onText event
let note = null;
let noteAttributes = null;
let noteResource = null;
let noteResourceAttributes = null;
let noteResourceRecognition = null;
const notes = [];
const nodes: Node[] = []; // LIFO list of nodes so that we know in which node we are in the onText event
let note: ExtractedNote = null;
let noteAttributes: Record<string, any> = null;
let noteResource: ExtractedResource = null;
let noteResourceAttributes: Record<string, any> = null;
let noteResourceRecognition: NoteResourceRecognition = null;
const notes: ExtractedNote[] = [];
let processingNotes = false;
const createErrorWithNoteTitle = (fnThis, error) => {
const createErrorWithNoteTitle = (fnThis: any, error: any) => {
const line = [];
const parser = fnThis ? fnThis._parser : null;
@@ -329,7 +395,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
return error;
};
stream.on('error', function(error) {
stream.on('error', function(error: any) {
importOptions.onError(createErrorWithNoteTitle(this, error));
});
@@ -417,11 +483,11 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
return true;
}
saxStream.on('error', function(error) {
saxStream.on('error', function(error: any) {
importOptions.onError(createErrorWithNoteTitle(this, error));
});
saxStream.on('text', handleSaxStreamEvent(function(text) {
saxStream.on('text', handleSaxStreamEvent(function(text: string) {
const n = currentNodeName();
if (noteAttributes) {
@@ -443,8 +509,8 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
fs.appendFileSync(noteResource.dataFilePath, text);
} else {
if (!(n in noteResource)) noteResource[n] = '';
noteResource[n] += text;
if (!(n in noteResource)) (noteResource as any)[n] = '';
(noteResource as any)[n] += text;
}
} else if (note) {
if (n == 'title') {
@@ -465,7 +531,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
}
}));
saxStream.on('opentag', handleSaxStreamEvent(function(node) {
saxStream.on('opentag', handleSaxStreamEvent(function(node: Node) {
const n = node.name.toLowerCase();
nodes.push(node);
@@ -488,7 +554,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
}
}));
saxStream.on('cdata', handleSaxStreamEvent(function(data) {
saxStream.on('cdata', handleSaxStreamEvent(function(data: any) {
const n = currentNodeName();
if (noteResourceRecognition) {
@@ -500,7 +566,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
}
}));
saxStream.on('closetag', handleSaxStreamEvent(function(n) {
saxStream.on('closetag', handleSaxStreamEvent(function(n: string) {
nodes.pop();
if (n == 'note') {
@@ -529,7 +595,7 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
note.longitude = noteAttributes.longitude;
note.altitude = noteAttributes.altitude;
note.author = noteAttributes.author ? noteAttributes.author.trim() : '';
note.is_todo = noteAttributes['reminder-order'] !== '0' && !!noteAttributes['reminder-order'];
note.is_todo = noteAttributes['reminder-order'] !== '0' && !!noteAttributes['reminder-order'] as any;
note.todo_due = dateToTimestamp(noteAttributes['reminder-time'], 0);
note.todo_completed = dateToTimestamp(noteAttributes['reminder-done-time'], 0);
note.order = dateToTimestamp(noteAttributes['reminder-order'], 0);
@@ -572,10 +638,11 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
saxStream.on('end', handleSaxStreamEvent(function() {
// Wait till there is no more notes to process.
const iid = shim.setInterval(() => {
processNotes().then(allDone => {
void processNotes().then(allDone => {
if (allDone) {
shim.clearTimeout(iid);
resolve();
if (needToDeleteFileToProcess) void shim.fsDriver().remove(fileToProcess);
resolve(null);
}
});
}, 500);
@@ -584,5 +651,3 @@ function importEnex(parentFolderId, filePath, importOptions = null) {
stream.pipe(saxStream);
});
}
module.exports = { importEnex };

View File

@@ -1,12 +1,11 @@
import { ImportExportResult } from './types';
import InteropService_Importer_Base from './InteropService_Importer_Base';
import Folder from '../../models/Folder';
import importEnex from '../../import-enex';
const { filename } = require('../../path-utils');
export default class InteropService_Importer_EnexToHtml extends InteropService_Importer_Base {
async exec(result: ImportExportResult): Promise<ImportExportResult> {
const { importEnex } = require('../../import-enex');
public async exec(result: ImportExportResult): Promise<ImportExportResult> {
let folder = this.options_.destinationFolder;
if (!folder) {

View File

@@ -1,13 +1,11 @@
import { ImportExportResult } from './types';
import importEnex from '../../import-enex';
import InteropService_Importer_Base from './InteropService_Importer_Base';
import Folder from '../../models/Folder';
const { filename } = require('../../path-utils');
export default class InteropService_Importer_EnexToMd extends InteropService_Importer_Base {
async exec(result: ImportExportResult) {
const { importEnex } = require('../../import-enex');
public async exec(result: ImportExportResult) {
let folder = this.options_.destinationFolder;
if (!folder) {

View File

@@ -18,6 +18,7 @@ import { initializeJoplinUtils } from './utils/joplinUtils';
import startServices from './utils/startServices';
import { credentialFile } from './utils/testing/testUtils';
import apiVersionHandler from './middleware/apiVersionHandler';
import clickJackingHandler from './middleware/clickJackingHandler';
const cors = require('@koa/cors');
const nodeEnvFile = require('node-env-file');
@@ -171,6 +172,7 @@ async function main() {
app.use(apiVersionHandler);
app.use(ownerHandler);
app.use(notificationHandler);
app.use(clickJackingHandler);
app.use(routeHandler);
await initConfig(env, envVariables);

View File

@@ -15,6 +15,14 @@ require('pg').types.setTypeParser(20, function(val: any) {
return parseInt(val, 10);
});
// Also need this to get integers for count() queries.
// https://knexjs.org/#Builder-count
declare module 'knex/types/result' {
interface Registry {
Count: number;
}
}
const logger = Logger.create('db');
// To prevent error "SQLITE_ERROR: too many SQL variables", SQL statements with

View File

@@ -0,0 +1,8 @@
import { AppContext, KoaNext } from '../utils/types';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
// https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html
ctx.response.set('Content-Security-Policy', 'frame-ancestors \'none\'');
ctx.response.set('X-Frame-Options', 'DENY');
return next();
}

View File

@@ -11,6 +11,8 @@ import Logger from '@joplin/lib/Logger';
const logger = Logger.create('BaseModel');
type SavePoint = string;
export interface SaveOptions {
isNew?: boolean;
skipValidation?: boolean;
@@ -49,6 +51,7 @@ export default abstract class BaseModel<T> {
private modelFactory_: Function;
private static eventEmitter_: EventEmitter = null;
private config_: Config;
private savePoints_: SavePoint[] = [];
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
this.db_ = db;
@@ -289,6 +292,25 @@ export default abstract class BaseModel<T> {
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
}
public async setSavePoint(): Promise<SavePoint> {
const name = `sp_${uuidgen()}`;
await this.db.raw(`SAVEPOINT ${name}`);
this.savePoints_.push(name);
return name;
}
public async rollbackSavePoint(savePoint: SavePoint) {
const last = this.savePoints_.pop();
if (last !== savePoint) throw new Error('Rollback save point does not match');
await this.db.raw(`ROLLBACK TO SAVEPOINT ${savePoint}`);
}
public async releaseSavePoint(savePoint: SavePoint) {
const last = this.savePoints_.pop();
if (last !== savePoint) throw new Error('Rollback save point does not match');
await this.db.raw(`RELEASE SAVEPOINT ${savePoint}`);
}
public async exists(id: string): Promise<boolean> {
const o = await this.load(id, { fields: ['id'] });
return !!o;

View File

@@ -106,13 +106,18 @@ export default class ItemModel extends BaseModel<Item> {
return path.replace(extractNameRegex, '$1');
}
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
public byShareIdQuery(shareId: Uuid, options: LoadOptions = {}): Knex.QueryBuilder {
return this
.db('items')
.select(this.selectFields(options, null, 'items'))
.where('jop_share_id', '=', shareId);
}
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
const query = this.byShareIdQuery(shareId, options);
return await query;
}
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
if (!jopIds.length) return [];

View File

@@ -1,7 +1,7 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, createItemTree } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, createItemTree, expectNotThrow, createNote } from '../utils/testing/testUtils';
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
import { ShareType } from '../services/database/types';
import { shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
import { inviteUserToShare, shareFolderWithUser, shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
describe('ShareModel', function() {
@@ -99,5 +99,80 @@ describe('ShareModel', function() {
expect(await models().item().load(noteItem.id)).toBeFalsy();
});
test('should count number of items in share', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
});
expect(await models().share().itemCountByShareId(share.id)).toBe(2);
await models().item().delete((await models().item().loadByJopId(user1.id, '00000000000000000000000000000001')).id);
await models().item().delete((await models().item().loadByJopId(user1.id, '000000000000000000000000000000F1')).id);
expect(await models().share().itemCountByShareId(share.id)).toBe(0);
});
test('should count number of items in share per recipient', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const { user: user3 } = await createUserAndSession(3);
await createUserAndSession(4); // To check that he's not included in the results since the items are not shared with him
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
});
await inviteUserToShare(share, session1.id, user3.email);
const rows = await models().share().itemCountByShareIdPerUser(share.id);
expect(rows.length).toBe(3);
expect(rows.find(r => r.user_id === user1.id).item_count).toBe(2);
expect(rows.find(r => r.user_id === user2.id).item_count).toBe(2);
expect(rows.find(r => r.user_id === user3.id).item_count).toBe(2);
});
test('should create user items for shared folder', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const { user: user3 } = await createUserAndSession(3);
await createUserAndSession(4); // To check that he's not included in the results since the items are not shared with him
const { share } = await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', {
'000000000000000000000000000000F1': {
'00000000000000000000000000000001': null,
},
});
// When running that function with a new user, it should get all the
// share items
expect((await models().userItem().byUserId(user3.id)).length).toBe(0);
await models().share().createSharedFolderUserItems(share.id, user3.id);
expect((await models().userItem().byUserId(user3.id)).length).toBe(2);
// Calling the function again should not throw - it should just ignore
// the items that have already been added.
await expectNotThrow(async () => models().share().createSharedFolderUserItems(share.id, user3.id));
// After adding a new note to the share, and calling the function, it
// should add the note to the other user collection.
expect(await models().share().itemCountByShareId(share.id)).toBe(2);
await createNote(session1.id, {
id: '00000000000000000000000000000003',
share_id: share.id,
});
expect(await models().share().itemCountByShareId(share.id)).toBe(3);
await models().share().createSharedFolderUserItems(share.id, user3.id);
expect(await models().share().itemCountByShareId(share.id)).toBe(3);
});
});

View File

@@ -8,6 +8,9 @@ import BaseModel, { AclAction, DeleteOptions, ValidateOptions } from './BaseMode
import { userIdFromUserContentUrl } from '../utils/routeUtils';
import { getCanShareFolder } from './utils/user';
import { isUniqueConstraintError } from '../db';
import Logger from '@joplin/lib/Logger';
const logger = Logger.create('ShareModel');
export default class ShareModel extends BaseModel<Share> {
@@ -215,6 +218,32 @@ export default class ShareModel extends BaseModel<Share> {
}
};
// This function add any missing item to a user's collection. Normally
// it shouldn't be necessary since items are added or removed based on
// the Change events, but it seems it can happen anyway, possibly due to
// a race condition somewhere. So this function corrects this by
// re-assigning any missing items.
//
// It should be relatively quick to call since it's restricted to shares
// that have recently changed, and the performed SQL queries are
// index-based.
const checkForMissingUserItems = async (shares: Share[]) => {
for (const share of shares) {
const realShareItemCount = await this.itemCountByShareId(share.id);
const shareItemCountPerUser = await this.itemCountByShareIdPerUser(share.id);
for (const row of shareItemCountPerUser) {
if (row.item_count < realShareItemCount) {
logger.warn(`checkForMissingUserItems: User is missing some items: Share ${share.id}: User ${row.user_id}`);
await this.createSharedFolderUserItems(share.id, row.user_id);
} else if (row.item_count > realShareItemCount) {
// Shouldn't be possible but log it just in case
logger.warn(`checkForMissingUserItems: User has too many items (??): Share ${share.id}: User ${row.user_id}`);
}
}
}
};
// This loop essentially applies the change made by one user to all the
// other users in the share.
//
@@ -260,6 +289,8 @@ export default class ShareModel extends BaseModel<Share> {
// too.
}
await checkForMissingUserItems(shares);
await this.models().keyValue().setValue('ShareService::latestProcessedChange', paginatedChanges.cursor);
}, 'ShareService::updateSharedItems3');
}
@@ -304,18 +335,13 @@ export default class ShareModel extends BaseModel<Share> {
}
}
// That should probably only be called when a user accepts the share
// invitation. At this point, we want to share all the items immediately.
// Afterwards, items that are added or removed are processed by the share
// service.
// The items that are added or removed from a share are processed by the
// share service, and added as user_utems to each user. This function
// however can be called after a user accept a share, or to correct share
// errors, but re-assigning all items to a user.
public async createSharedFolderUserItems(shareId: Uuid, userId: Uuid) {
const items = await this.models().item().byShareId(shareId, { fields: ['id'] });
await this.withTransaction(async () => {
for (const item of items) {
await this.models().userItem().add(userId, item.id);
}
}, 'ShareModel::createSharedFolderUserItems');
const query = this.models().item().byShareIdQuery(shareId, { fields: ['id', 'name'] });
await this.models().userItem().addMulti(userId, query);
}
public async shareFolder(owner: User, folderId: string): Promise<Share> {
@@ -368,4 +394,23 @@ export default class ShareModel extends BaseModel<Share> {
}, 'ShareModel::delete');
}
public async itemCountByShareId(shareId: Uuid): Promise<number> {
const r = await this
.db('items')
.count('id', { as: 'item_count' })
.where('jop_share_id', '=', shareId);
return r[0].item_count;
}
public async itemCountByShareIdPerUser(shareId: Uuid): Promise<{ item_count: number; user_id: Uuid }[]> {
return this.db('user_items')
.select(this.db.raw('user_id, count(user_id) as item_count'))
.whereIn('item_id',
this.db('items')
.select('id')
.where('jop_share_id', '=', shareId)
).groupBy('user_id') as any;
}
}

View File

@@ -139,7 +139,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
await this.withTransaction(async () => {
await this.delete(shareUsers.map(s => s.id));
}, 'ShareUserModel::delete');
}, 'ShareUserModel::deleteByShare');
}
public async delete(id: string | string[], _options: DeleteOptions = {}): Promise<void> {

View File

@@ -1,7 +1,8 @@
import { ChangeType, ItemType, UserItem, Uuid } from '../services/database/types';
import { ChangeType, Item, ItemType, UserItem, Uuid } from '../services/database/types';
import BaseModel, { DeleteOptions, LoadOptions, SaveOptions } from './BaseModel';
import { unique } from '../utils/array';
import { ErrorNotFound } from '../utils/errors';
import { Knex } from 'knex';
interface DeleteByShare {
id: Uuid;
@@ -123,25 +124,38 @@ export default class UserItemModel extends BaseModel<UserItem> {
await this.deleteBy({ byShareId: shareId, byUserId: userId });
}
public async addMulti(userId: Uuid, itemsQuery: Knex.QueryBuilder | Item[], options: SaveOptions = {}): Promise<void> {
const items: Item[] = Array.isArray(itemsQuery) ? itemsQuery : await itemsQuery.whereNotIn('id', this.db('user_items').select('item_id').where('user_id', '=', userId));
if (!items.length) return;
await this.withTransaction(async () => {
for (const item of items) {
if (!('name' in item) || !('id' in item)) throw new Error('item.id and item.name must be set');
await super.save({
user_id: userId,
item_id: item.id,
}, options);
if (this.models().item().shouldRecordChange(item.name)) {
await this.models().change().save({
item_type: ItemType.UserItem,
item_id: item.id,
item_name: item.name,
type: ChangeType.Create,
previous_item: '',
user_id: userId,
});
}
}
}, 'UserItemModel::addMulti');
}
public async save(userItem: UserItem, options: SaveOptions = {}): Promise<UserItem> {
if (userItem.id) throw new Error('User items cannot be modified (only created or deleted)'); // Sanity check - shouldn't happen
const item = await this.models().item().load(userItem.item_id, { fields: ['id', 'name'] });
return this.withTransaction(async () => {
if (this.models().item().shouldRecordChange(item.name)) {
await this.models().change().save({
item_type: ItemType.UserItem,
item_id: userItem.item_id,
item_name: item.name,
type: ChangeType.Create,
previous_item: '',
user_id: userItem.user_id,
});
}
return super.save(userItem, options);
}, 'UserItemModel::save');
await this.addMulti(userItem.user_id, [item], options);
return this.byUserAndItemId(userItem.user_id, item.id);
}
public async delete(_id: string | string[], _options: DeleteOptions = {}): Promise<void> {

View File

@@ -66,6 +66,21 @@ async function createItemTree3(sessionId: Uuid, userId: Uuid, parentFolderId: st
}
}
export async function inviteUserToShare(share: Share, sharerSessionId: string, recipientEmail: string, acceptShare: boolean = true) {
let shareUser = await postApi(sharerSessionId, `shares/${share.id}/users`, {
email: recipientEmail,
}) as ShareUser;
shareUser = await models().shareUser().load(shareUser.id);
if (acceptShare) {
const session = await models().session().createUserSession(shareUser.user_id);
await patchApi(session.id, `share_users/${shareUser.id}`, { status: ShareUserStatus.Accepted });
}
return shareUser;
}
export async function shareFolderWithUser(sharerSessionId: string, shareeSessionId: string, sharedFolderId: string, itemTree: any, acceptShare: boolean = true): Promise<ShareResult> {
itemTree = Array.isArray(itemTree) ? itemTree : convertTree(itemTree);
@@ -93,15 +108,7 @@ export async function shareFolderWithUser(sharerSessionId: string, shareeSession
}
}
let shareUser = await postApi(sharerSessionId, `shares/${share.id}/users`, {
email: sharee.email,
}) as ShareUser;
shareUser = await models().shareUser().load(shareUser.id);
if (acceptShare) {
await patchApi(shareeSessionId, `share_users/${shareUser.id}`, { status: ShareUserStatus.Accepted });
}
const shareUser = await inviteUserToShare(share, sharerSessionId, sharee.email, acceptShare);
await models().share().updateSharedItems3();
@@ -146,7 +153,6 @@ export async function shareWithUserAndAccept(sharerSessionId: string, shareeSess
await patchApi(shareeSessionId, `share_users/${shareUser.id}`, { status: ShareUserStatus.Accepted });
await models().share().updateSharedItems3();
// await models().share().updateSharedItems2(sharee.id);
return { share, item, shareUser };
}

View File

@@ -0,0 +1,37 @@
import { getIsPreRelease, getVersionFromTag } from './buildServerDocker';
describe('buildServerDocker', function() {
test('should get the tag version', async () => {
type TestCase = [string, boolean, string];
const testCases: TestCase[] = [
['server-v1.1.2-beta', true, '1.1.2-beta'],
['server-v1.1.2', false, '1.1.2'],
];
for (const testCase of testCases) {
const [tagName, isPreRelease, expected] = testCase;
const actual = getVersionFromTag(tagName, isPreRelease);
expect(actual).toBe(expected);
}
expect(() => getVersionFromTag('app-cli-v1.0.0', false)).toThrow();
});
test('should check if it is a pre-release', async () => {
type TestCase = [string, boolean];
const testCases: TestCase[] = [
['server-v1.1.2-beta', true],
['server-v1.1.2', false],
];
for (const testCase of testCases) {
const [tagName, expected] = testCase;
const actual = getIsPreRelease(tagName);
expect(actual).toBe(expected);
}
});
});

View File

@@ -1,14 +1,14 @@
import { execCommand2, rootDir } from './tool-utils';
import * as moment from 'moment';
function getVersionFromTag(tagName: string, isPreRelease: boolean): string {
export function getVersionFromTag(tagName: string, isPreRelease: boolean): string {
if (tagName.indexOf('server-') !== 0) throw new Error(`Invalid tag: ${tagName}`);
const s = tagName.split('-');
const suffix = isPreRelease ? '-beta' : '';
return s[1].substr(1) + suffix;
}
function getIsPreRelease(tagName: string): boolean {
export function getIsPreRelease(tagName: string): boolean {
return tagName.indexOf('-beta') > 0;
}
@@ -51,8 +51,10 @@ async function main() {
}
}
main().catch((error) => {
console.error('Fatal error');
console.error(error);
process.exit(1);
});
if (require.main === module) {
main().catch((error) => {
console.error('Fatal error');
console.error(error);
process.exit(1);
});
}

View File

@@ -0,0 +1,14 @@
module.exports = {
testMatch: [
'**/*.test.js',
],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
],
testEnvironment: 'node',
// setupFilesAfterEnv: [`${__dirname}/jest.setup.js`],
// slowTestThreshold: 40,
};

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"scripts": {
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --project tsconfig.json"
"watch": "tsc --watch --project tsconfig.json",
"test": "jest --verbose=false",
"test-ci": "npm run test"
},
"publishConfig": {
"access": "public"
@@ -41,6 +43,8 @@
"@types/fs-extra": "^9.0.6",
"@types/mustache": "^0.8.32",
"@types/node": "^14.14.6",
"@types/jest": "^26.0.15",
"jest": "^26.6.3",
"gulp": "^4.0.2",
"sass": "^1.39.2",
"sqlite3": "^5.0.0",

View File

@@ -15,7 +15,11 @@ async function main() {
const tagName = `server-${version}${versionSuffix}`;
const changelogPath = `${rootDir}/readme/changelog_server.md`;
await completeReleaseWithChangelog(changelogPath, version, tagName, 'Server', isPreRelease);
// We don't mark the changelog entry as pre-release because they all are
// initially. It's only after a number of days once it's clear that the
// release is stable that it is marked as "latest".
await completeReleaseWithChangelog(changelogPath, version, tagName, 'Server', false);
}
main().catch((error) => {

View File

@@ -0,0 +1,20 @@
import { execCommand2 } from './tool-utils';
async function main() {
const argv = require('yargs').argv;
if (!argv._.length) throw new Error('Version number is required');
const version = argv._[0];
await execCommand2(`docker pull "joplin/server:${version}"`);
await execCommand2(`docker tag "joplin/server:${version}" "joplin/server:latest"`);
await execCommand2('docker push joplin/server:latest');
}
if (require.main === module) {
main().catch((error) => {
console.error('Fatal error');
console.error(error);
process.exit(1);
});
}

View File

@@ -1,6 +1,6 @@
# Joplin Server Changelog
## [server-v2.4.9-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.4.9-beta) (Pre-release) - 2021-09-22T16:31:23Z
## [server-v2.4.9](https://github.com/laurent22/joplin/releases/tag/server-v2.4.9-beta) - 2021-09-22T16:31:23Z
- New: Add support for changing user own email (63e88c0)
- Improved: Allow an admin to impersonate a user (03b4b6e)
@@ -17,21 +17,21 @@
- Improved: Sync deleted items first to allow fixing oversized accounts (43c594b)
- Fixed: Fixed calculating total item size after an item has been deleted (024967c)
## [server-v2.4.8-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.4.8-beta) (Pre-release) - 2021-09-15T22:16:59Z
## [server-v2.4.8](https://github.com/laurent22/joplin/releases/tag/server-v2.4.8-beta) - 2021-09-15T22:16:59Z
- New: Added support for app level slow SQL query log (5e8b742)
## [server-v2.4.7-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.4.7-beta) (Pre-release) - 2021-09-15T15:58:46Z
## [server-v2.4.7](https://github.com/laurent22/joplin/releases/tag/server-v2.4.7-beta) - 2021-09-15T15:58:46Z
- Improved: Improve flag logic (c229821)
- Fixed: Fixed handling of brute force limiter by getting correct user IP (3ce947e)
## [server-v2.4.6-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.4.6-beta) (Pre-release) - 2021-09-14T15:02:21Z
## [server-v2.4.6](https://github.com/laurent22/joplin/releases/tag/server-v2.4.6-beta) - 2021-09-14T15:02:21Z
- New: Add link to Stripe subscription page to manage payment details (4e7fe66)
- New: Add transaction info to debug deadlock issues (01b653f)
## [server-v2.4.3-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.4.3-beta) (Pre-release) - 2021-09-02T17:49:11Z
## [server-v2.4.3](https://github.com/laurent22/joplin/releases/tag/server-v2.4.3-beta) - 2021-09-02T17:49:11Z
- New: Added Help page for Joplin Cloud (6520a48)
- New: Added icon next to profile button (5805a41)
@@ -54,19 +54,19 @@
- Fixed: Fix missing CSS file error (#5309 by [@whalehub](https://github.com/whalehub))
- Fixed: Fixed second duration (c7421df)
## [server-v2.3.7-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.3.7-beta) (Pre-release) - 2021-08-13T21:20:17Z
## [server-v2.3.7](https://github.com/laurent22/joplin/releases/tag/server-v2.3.7-beta) - 2021-08-13T21:20:17Z
- Fixed: Fix migrations (a9961ae)
## [server-v2.3.6-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.3.6-beta) (Pre-release) - 2021-08-13T20:59:41Z
## [server-v2.3.6](https://github.com/laurent22/joplin/releases/tag/server-v2.3.6-beta) - 2021-08-13T20:59:41Z
- Fixed: Fix migrations (f518549)
## [server-v2.3.5-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.3.5-beta) (Pre-release) - 2021-08-13T18:01:20Z
## [server-v2.3.5](https://github.com/laurent22/joplin/releases/tag/server-v2.3.5-beta) - 2021-08-13T18:01:20Z
- Fixed: Fixed pagination link styling (d42d181)
## [server-v2.3.4-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.3.4-beta) (Pre-release) - 2021-08-13T16:56:17Z
## [server-v2.3.4](https://github.com/laurent22/joplin/releases/tag/server-v2.3.4-beta) - 2021-08-13T16:56:17Z
- Improved: Allow setting email key to prevent the same email to be sent multiple times (391204c)
- Improved: Clarify beta transition message (c4fcfec)
@@ -77,7 +77,7 @@
- Fixed: Fix regression (6359c9c)
- Fixed: Fixed layout of notes on mobile devices (#5269)
## [server-v2.2.11-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.11-beta) (Pre-release) - 2021-08-03T18:48:00Z
## [server-v2.2.11](https://github.com/laurent22/joplin/releases/tag/server-v2.2.11-beta) - 2021-08-03T18:48:00Z
- Improved: Disable beta account once expired (785248b)
- Improved: Handle beta user upgrade (8910c87)
@@ -88,7 +88,7 @@
- Improved: Allows providing a coupon when creating the Stripe checkout session (b5b6111)
## [server-v2.2.9-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.9-beta) (Pre-release) - 2021-07-31T13:52:53Z
## [server-v2.2.9](https://github.com/laurent22/joplin/releases/tag/server-v2.2.9-beta) - 2021-07-31T13:52:53Z
- New: Add Docker major, minor and beta version tags (#5237 by [@JackGruber](https://github.com/JackGruber))
- New: Add support for Stripe yearly subscriptions (f2547fe)
@@ -96,7 +96,7 @@
- Fixed: Fixed certain URLs (282f782)
- Fixed: Published notes that contain non-alphabetical characters could end up being truncated (#5229)
## [server-v2.2.8-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.8-beta) (Pre-release) - 2021-07-24T16:55:58Z
## [server-v2.2.8](https://github.com/laurent22/joplin/releases/tag/server-v2.2.8-beta) - 2021-07-24T16:55:58Z
- New: Added form tokens to prevent CSRF attacks (CVE-2021-23431) (19b45de)
- Improved: Allow admin to change Stripe subscription (75a421e)
@@ -110,37 +110,37 @@
- Improved: Moved email templates to separate files (6a93cb2)
- Improved: Set default of env SUPPORT_EMAIL to "SUPPORT_EMAIL" to make it clear it needs to be set (92520e5)
## [server-v2.2.7-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.7-beta) (Pre-release) - 2021-07-11T17:31:42Z
## [server-v2.2.7](https://github.com/laurent22/joplin/releases/tag/server-v2.2.7-beta) - 2021-07-11T17:31:42Z
- New: Added support for resetting user password (62b6198)
- Improved: Check password complexity (240cb35)
- Improved: Disallow changing email address until a secure solution to change it is implemented (f8d2c26)
- Fixed: Fixed mail queue as some emails were not being processed (89f4ca1)
## [server-v2.2.6-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.6-beta) (Pre-release) - 2021-07-09T15:57:47Z
## [server-v2.2.6](https://github.com/laurent22/joplin/releases/tag/server-v2.2.6-beta) - 2021-07-09T15:57:47Z
- New: Add Docker image labels (#5158 by [@JackGruber](https://github.com/JackGruber))
- Fixed: Fixed change processing logic (5a27d4d)
- Fixed: Fixed styling of shared note (6c1a6b0)
## [server-v2.2.5-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.5-beta) (Pre-release) - 2021-07-03T21:40:37Z
## [server-v2.2.5](https://github.com/laurent22/joplin/releases/tag/server-v2.2.5-beta) - 2021-07-03T21:40:37Z
- Improved: Make app context immutable and derive the per-request context properties from it (e210926)
## [server-v2.2.4-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.4-beta) (Pre-release) - 2021-07-03T21:10:29Z
## [server-v2.2.4](https://github.com/laurent22/joplin/releases/tag/server-v2.2.4-beta) - 2021-07-03T21:10:29Z
- Fixed: Fixed issue with user sessions being mixed up (238cc86)
## [server-v2.2.3-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.3-beta) (Pre-release) - 2021-07-03T19:38:36Z
## [server-v2.2.3](https://github.com/laurent22/joplin/releases/tag/server-v2.2.3-beta) - 2021-07-03T19:38:36Z
- Fixed: Fixed size of a database field (264f36f)
## [server-v2.2.2-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.2-beta) (Pre-release) - 2021-07-03T18:28:35Z
## [server-v2.2.2](https://github.com/laurent22/joplin/releases/tag/server-v2.2.2-beta) - 2021-07-03T18:28:35Z
- Improved: Improved logging and reliability of cron tasks (d99c34f)
- Improved: Only emit "created" event when new user is saved (8883df2)
## [server-v2.2.1-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.2.1-beta) (Pre-release) - 2021-07-03T15:41:32Z
## [server-v2.2.1](https://github.com/laurent22/joplin/releases/tag/server-v2.2.1-beta) - 2021-07-03T15:41:32Z
- New: Add support for account max total size (b507fbf)
- Improved: Display max size info in dashboard (3d18514)
@@ -149,21 +149,21 @@
- Improved: Normalize email addresses before saving them (427218b)
- Improved: Remove dangerous "Delete all" button for now (125af75)
## [server-v2.1.6-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.6-beta) (Pre-release) - 2021-06-24T10:01:46Z
## [server-v2.1.6](https://github.com/laurent22/joplin/releases/tag/server-v2.1.6-beta) - 2021-06-24T10:01:46Z
- Fixed: Fixed accessing main website (Regression) (f868797)
## [server-v2.1.5-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.5-beta) (Pre-release) - 2021-06-24T08:26:38Z
## [server-v2.1.5](https://github.com/laurent22/joplin/releases/tag/server-v2.1.5-beta) - 2021-06-24T08:26:38Z
- New: Add support for X-API-MIN-VERSION header (51f3c00)
## [server-v2.1.4-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.4-beta) (Pre-release) - 2021-06-24T07:26:03Z
## [server-v2.1.4](https://github.com/laurent22/joplin/releases/tag/server-v2.1.4-beta) - 2021-06-24T07:26:03Z
- Improved: Split permission to share note or folder (0c12c7f)
- Fixed: Fixed handling of max item size for encrypted items (112157e)
- Fixed: Fixed transaction locking issue when a sub-transaction fails (12aae48)
## [server-v2.1.3-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.3-beta) (Pre-release) - 2021-06-19T14:15:06Z
## [server-v2.1.3](https://github.com/laurent22/joplin/releases/tag/server-v2.1.3-beta) - 2021-06-19T14:15:06Z
- New: Add support for uploading multiple items in one request (3b9c02e)
@@ -198,14 +198,14 @@
- Improved: Handle custom user content URLs (a36b13d)
- Fixed: Fixed error when creating user (594084e)
## [server-v2.0.9-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.0.9-beta) (Pre-release) - 2021-06-11T16:49:05Z
## [server-v2.0.9](https://github.com/laurent22/joplin/releases/tag/server-v2.0.9-beta) - 2021-06-11T16:49:05Z
- New: Add navbar on login and sign up page (7a3a208)
- New: Added option to enable or disable stack traces (5614eb9)
- Improved: Handle custom user content URLs (a36b13d)
- Fixed: Fixed error when creating user (594084e)
## [server-v2.0.6](https://github.com/laurent22/joplin/releases/tag/server-v2.0.6) (Pre-release) - 2021-06-07T17:27:27Z
## [server-v2.0.6](https://github.com/laurent22/joplin/releases/tag/server-v2.0.6) - 2021-06-07T17:27:27Z
- New: Add Stripe integration (770af6a)
- New: Add request duration to log (c8d7ecb)
@@ -214,21 +214,21 @@
- Improved: Check share ID when uploading a note (3c41b45)
- Improved: Load shared user content from correct domain (de45740)
## [server-v2.0.5](https://github.com/laurent22/joplin/releases/tag/server-v2.0.5) (Pre-release) - 2021-06-02T08:14:47Z
## [server-v2.0.5](https://github.com/laurent22/joplin/releases/tag/server-v2.0.5) - 2021-06-02T08:14:47Z
- New: Add version number on website (0ef7e98)
- New: Added signup pages (41ed66d)
- Improved: Allow disabling item upload for a user (f8a26cf)
## [server-v2.0.4](https://github.com/laurent22/joplin/releases/tag/server-v2.0.4) (Pre-release) - 2021-05-25T18:33:11Z
## [server-v2.0.4](https://github.com/laurent22/joplin/releases/tag/server-v2.0.4) - 2021-05-25T18:33:11Z
- Fixed: Fixed Item and Log page when using Postgres (ee0f237)
## [server-v2.0.3](https://github.com/laurent22/joplin/releases/tag/server-v2.0.3) (Pre-release) - 2021-05-25T18:08:46Z
## [server-v2.0.3](https://github.com/laurent22/joplin/releases/tag/server-v2.0.3) - 2021-05-25T18:08:46Z
- Fixed: Fixed handling of request origin (12a6634)
## [server-v2.0.2](https://github.com/laurent22/joplin/releases/tag/server-v2.0.2) (Pre-release) - 2021-05-25T19:15:50Z
## [server-v2.0.2](https://github.com/laurent22/joplin/releases/tag/server-v2.0.2) - 2021-05-25T19:15:50Z
- New: Add mailer service (ed8ee67)
- New: Add support for item size limit (6afde54)
@@ -248,7 +248,7 @@
- Fixed: Fixed deleting a note that has been shared (489995d)
- Fixed: Make sure temp files are deleted after upload is done (#4540)
## [server-v2.0.1](https://github.com/laurent22/joplin/releases/tag/server-v2.0.1) (Pre-release) - 2021-05-14T13:55:45Z
## [server-v2.0.1](https://github.com/laurent22/joplin/releases/tag/server-v2.0.1) - 2021-05-14T13:55:45Z
- New: Add support for sharing notes via a link (ccbc329)
- New: Add support for sharing a folder (#4772)

View File

@@ -18,5 +18,9 @@ Bank Transfer | **IBAN:** FR76 4061 8803 5200 0400 7415 938<br>**BIC/SWIFT:** BO
Finally, there are other ways to support the development of Joplin:
- Consider rating the app on [Google Play](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) or [App Store](https://itunes.apple.com/us/app/joplin/id1315599797).
- Vote for or review the app on [alternativeTo](https://alternativeto.net/software/joplin/about/) or [Product Hunt](https://www.producthunt.com/posts/joplin).
- [Create or update a translation](https://joplinapp.org/help/#localisation).
- [Help us test pre-releases](https://joplinapp.org/prereleases/).
- [Improve the Wikipedia article](https://en.wikipedia.org/wiki/Joplin_(software)).

View File

@@ -2,8 +2,8 @@
Pre-releases are available for the desktop application. They are pretty much like regular releases, except that they have not yet been tested by many users, so it is possible that a bug or two went through.
You can help the development of Joplin by choosing to receive these early releases when updating the application. If you find any bug or other issue, you may report it [on the forum](https://discourse.joplinapp.org/) or [GitHub](https://github.com/laurent22/joplin/issues).
You can help the development of Joplin by choosing to receive these early releases when updating the application. If you find any bug or other issue, please report it on [GitHub](https://github.com/laurent22/joplin/issues) or the forum (if you do not have a GitHub account). Any bug report during the pre-release phase is invaluable to us, as it allows making the app more reliable before making it available to more people.
In general it is safe to use these pre-releases (they do not include any experimental or unstable features). In fact most pre-release eventually become regular releases after a few days.
In general it is safe to use these pre-releases as they do not include any experimental or unstable features. Hundreds of users run them without any issue. Morever even if you do find an issue, you can report it and it is usually fixed very quickly as these bugs are given the highest priority.
To have access to these pre-releases, simply go to [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/config_screen.md) and tick the box "**Get pre-releases when checking for updates**".
To have access to these pre-releases, simply go to [Configuration &gt; General](https://github.com/laurent22/joplin/blob/dev/readme/config_screen.md) and tick the box "**Get pre-releases when checking for updates**".