mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-24 08:12:24 +02:00
Merge branch 'dev' into release-2.3
This commit is contained in:
commit
2a732ba03d
@ -7,4 +7,6 @@ packages/app-cli
|
||||
packages/app-mobile
|
||||
packages/app-clipper
|
||||
packages/generator-joplin
|
||||
packages/plugin-repo-cli
|
||||
packages/plugin-repo-cli
|
||||
packages/server/db-*.sqlite
|
||||
packages/server/temp
|
||||
|
2
.github/scripts/run_ci.sh
vendored
2
.github/scripts/run_ci.sh
vendored
@ -134,7 +134,7 @@ if [[ $GIT_TAG_NAME = v* ]]; then
|
||||
elif [[ $GIT_TAG_NAME = server-v* ]] && [[ $IS_LINUX = 1 ]]; then
|
||||
echo "Step: Building Docker Image..."
|
||||
cd "$ROOT_DIR"
|
||||
npm run buildServerDocker -- --tag-name $GIT_TAG_NAME
|
||||
npm run buildServerDocker -- --tag-name $GIT_TAG_NAME --push-images
|
||||
else
|
||||
echo "Step: Building but *not* publishing desktop application..."
|
||||
USE_HARD_LINKS=false npm run dist -- --publish=never
|
||||
|
@ -33,10 +33,11 @@ const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../ErrorBoundary';
|
||||
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml';
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
function markupRenderOptions(override: any = null) {
|
||||
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
|
||||
return { ...override };
|
||||
}
|
||||
|
||||
@ -384,6 +385,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
if (Setting.value('style.editor.monospaceFontFamily')) monospaceFonts.push(`"${Setting.value('style.editor.monospaceFontFamily')}"`);
|
||||
monospaceFonts.push('monospace');
|
||||
|
||||
const maxWidthCss = props.contentMaxWidth ? `
|
||||
margin-right: auto !important;
|
||||
margin-left: auto !important;
|
||||
max-width: ${props.contentMaxWidth}px !important;
|
||||
` : '';
|
||||
|
||||
const element = document.createElement('style');
|
||||
element.setAttribute('id', 'codemirrorStyle');
|
||||
document.head.appendChild(element);
|
||||
@ -418,6 +425,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
/* Add a fixed right padding to account for the appearance (and disappearance) */
|
||||
/* of the sidebar */
|
||||
padding-right: 10px !important;
|
||||
${maxWidthCss}
|
||||
}
|
||||
|
||||
/* This enforces monospace for certain elements (code, tables, etc.) */
|
||||
@ -467,6 +475,20 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
color: ${theme.codeColor};
|
||||
}
|
||||
|
||||
div.CodeMirror span.cm-comment.cm-jn-inline-code {
|
||||
border: 1px solid ${theme.codeBorderColor};
|
||||
background-color: ${theme.codeBackgroundColor};
|
||||
padding-right: .2em;
|
||||
padding-left: .2em;
|
||||
border-radius: .25em;
|
||||
}
|
||||
|
||||
div.CodeMirror pre.cm-jn-code-block {
|
||||
background-color: ${theme.codeBackgroundColor};
|
||||
padding-right: .2em;
|
||||
padding-left: .2em;
|
||||
}
|
||||
|
||||
div.CodeMirror span.cm-strong {
|
||||
color: ${theme.colorBright};
|
||||
}
|
||||
@ -533,7 +555,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
return () => {
|
||||
document.head.removeChild(element);
|
||||
};
|
||||
}, [props.themeId]);
|
||||
}, [props.themeId, props.contentMaxWidth]);
|
||||
|
||||
const webview_domReady = useCallback(() => {
|
||||
setWebviewReady(true);
|
||||
@ -572,7 +594,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
|
||||
}
|
||||
|
||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
|
||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
|
||||
resourceInfos: props.resourceInfos,
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
@ -795,6 +820,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
viewerStyle={styles.viewer}
|
||||
onIpcMessage={webview_ipcMessage}
|
||||
onDomReady={webview_domReady}
|
||||
contentMaxWidth={props.contentMaxWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ interface JoplinModeState {
|
||||
outer: any;
|
||||
openCharacter: string;
|
||||
inTable: boolean;
|
||||
inCodeBlock: boolean;
|
||||
inner: any;
|
||||
}
|
||||
|
||||
@ -48,6 +49,7 @@ export default function useJoplinMode(CodeMirror: any) {
|
||||
outer: CodeMirror.startState(markdownMode),
|
||||
openCharacter: '',
|
||||
inTable: false,
|
||||
inCodeBlock: false,
|
||||
inner: CodeMirror.startState(stex),
|
||||
};
|
||||
},
|
||||
@ -57,6 +59,7 @@ export default function useJoplinMode(CodeMirror: any) {
|
||||
outer: CodeMirror.copyState(markdownMode, state.outer),
|
||||
openCharacter: state.openCharacter,
|
||||
inTable: state.inTable,
|
||||
inCodeBlock: state.inCodeBlock,
|
||||
inner: CodeMirror.copyState(stex, state.inner),
|
||||
};
|
||||
},
|
||||
@ -115,9 +118,26 @@ export default function useJoplinMode(CodeMirror: any) {
|
||||
let isMonospace = false;
|
||||
// After being passed to the markdown mode we can check if the
|
||||
// code state variables are set
|
||||
// Code Block
|
||||
if (state.outer.code || (state.outer.thisLine && state.outer.thisLine.fencedCodeEnd)) {
|
||||
// Code
|
||||
if (state.outer.code > 0) {
|
||||
// state.outer.code holds the number of preceding backticks
|
||||
// anything > 0 backticks is an inline-code-block
|
||||
// -1 is used for actual code blocks
|
||||
isMonospace = true;
|
||||
token = `${token} jn-inline-code`;
|
||||
} else if (state.outer.thisLine && state.outer.thisLine.fencedCodeEnd) {
|
||||
state.inCodeBlock = false;
|
||||
isMonospace = true;
|
||||
token = `${token} line-cm-jn-code-block`;
|
||||
} else if (state.outer.code === -1 || state.inCodeBlock) {
|
||||
state.inCodeBlock = true;
|
||||
isMonospace = true;
|
||||
token = `${token} line-cm-jn-code-block`;
|
||||
} else if (stream.pos > 0 && stream.string[stream.pos - 1] === '`' &&
|
||||
!!token && token.includes('comment')) {
|
||||
// This grabs the closing backtick for inline Code
|
||||
isMonospace = true;
|
||||
token = `${token} jn-inline-code`;
|
||||
}
|
||||
// Indented Code
|
||||
if (state.outer.indentedCode) {
|
||||
@ -163,6 +183,10 @@ export default function useJoplinMode(CodeMirror: any) {
|
||||
}
|
||||
|
||||
state.inTable = false;
|
||||
|
||||
if (state.inCodeBlock) return 'line-cm-jn-code-block';
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
electricChars: markdownMode.electricChars,
|
||||
|
@ -14,18 +14,18 @@ import { _, closestSupportedLocale } from '@joplin/lib/locale';
|
||||
import useContextMenu from './utils/useContextMenu';
|
||||
import { copyHtmlToClipboard } from '../../utils/clipboardUtils';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import setupToolbarButtons from './utils/setupToolbarButtons';
|
||||
import { plainTextToHtml } from '@joplin/lib/htmlUtils';
|
||||
import openEditDialog from './utils/openEditDialog';
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
const { clipboard } = require('electron');
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
|
||||
function markupRenderOptions(override: any = null) {
|
||||
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
|
||||
return {
|
||||
plugins: {
|
||||
checkbox: {
|
||||
@ -148,8 +148,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
if (!resourceMd) return;
|
||||
const result = await props.markupToHtml(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, resourceMd, markupRenderOptions({ bodyOnly: true }));
|
||||
editor.insertContent(result.html);
|
||||
// editor.fire('joplinChange');
|
||||
// dispatchDidUpdate(editor);
|
||||
}, [props.markupToHtml, editor]);
|
||||
|
||||
const insertResourcesIntoContentRef = useRef(null);
|
||||
|
@ -159,8 +159,8 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
customCss: props.customCss,
|
||||
});
|
||||
|
||||
return markupToHtml.allAssets(markupLanguage, theme);
|
||||
}, [props.themeId, props.customCss]);
|
||||
return markupToHtml.allAssets(markupLanguage, theme, { contentMaxWidth: props.contentMaxWidth });
|
||||
}, [props.themeId, props.customCss, props.contentMaxWidth]);
|
||||
|
||||
const handleProvisionalFlag = useCallback(() => {
|
||||
if (props.isProvisional) {
|
||||
@ -400,6 +400,7 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
noteToolbarButtonInfos: props.toolbarButtonInfos,
|
||||
plugins: props.plugins,
|
||||
fontSize: Setting.value('style.editor.fontSize'),
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
};
|
||||
|
||||
let editor = null;
|
||||
@ -601,6 +602,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
setTagsToolbarButtonInfo: toolbarButtonUtils.commandsToToolbarButtons([
|
||||
'setTags',
|
||||
], whenClauseContext)[0],
|
||||
contentMaxWidth: state.settings['style.editor.contentMaxWidth'],
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUt
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml';
|
||||
import { MarkupToHtmlOptions } from './useMarkupToHtml';
|
||||
|
||||
export interface ToolbarButtonInfos {
|
||||
[key: string]: ToolbarButtonInfo;
|
||||
@ -37,6 +38,7 @@ export interface NoteEditorProps {
|
||||
toolbarButtonInfos: ToolbarButtonInfo[];
|
||||
setTagsToolbarButtonInfo: ToolbarButtonInfo;
|
||||
richTextBannerDismissed: boolean;
|
||||
contentMaxWidth: number;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorProps {
|
||||
@ -51,7 +53,7 @@ export interface NoteBodyEditorProps {
|
||||
onWillChange(event: any): void;
|
||||
onMessage(event: any): void;
|
||||
onScroll(event: any): void;
|
||||
markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: any)=> Promise<RenderResult>;
|
||||
markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
|
||||
htmlToMarkdown: Function;
|
||||
allAssets: (markupLanguage: MarkupLanguage)=> Promise<RenderResultPluginAsset[]>;
|
||||
disabled: boolean;
|
||||
@ -67,6 +69,7 @@ export interface NoteBodyEditorProps {
|
||||
noteToolbarButtonInfos: ToolbarButtonInfo[];
|
||||
plugins: PluginStates;
|
||||
fontSize: number;
|
||||
contentMaxWidth: number;
|
||||
}
|
||||
|
||||
export interface FormNote {
|
||||
|
@ -13,9 +13,12 @@ interface HookDependencies {
|
||||
plugins: PluginStates;
|
||||
}
|
||||
|
||||
interface MarkupToHtmlOptions {
|
||||
export interface MarkupToHtmlOptions {
|
||||
replaceResourceInternalToExternalLinks?: boolean;
|
||||
resourceInfos?: ResourceInfos;
|
||||
contentMaxWidth?: number;
|
||||
plugins?: Record<string, any>;
|
||||
bodyOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
|
@ -972,6 +972,8 @@ class Setting extends BaseModel {
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'style.editor.contentMaxWidth': { value: 600, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space.') },
|
||||
|
||||
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
|
||||
|
||||
// TODO: Is there a better way to do this? The goal here is to simply have
|
||||
|
@ -1,6 +1,7 @@
|
||||
import MdToHtml from './MdToHtml';
|
||||
import HtmlToHtml from './HtmlToHtml';
|
||||
import htmlUtils from './htmlUtils';
|
||||
import { Options as NoteStyleOptions } from './noteStyle';
|
||||
const MarkdownIt = require('markdown-it');
|
||||
|
||||
export enum MarkupLanguage {
|
||||
@ -48,7 +49,7 @@ export default class MarkupToHtml {
|
||||
private options_: Options;
|
||||
private rawMarkdownIt_: any;
|
||||
|
||||
public constructor(options: Options) {
|
||||
public constructor(options: Options = null) {
|
||||
this.options_ = {
|
||||
ResourceModel: {
|
||||
isResourceUrl: () => false,
|
||||
@ -119,7 +120,7 @@ export default class MarkupToHtml {
|
||||
return this.renderer(markupLanguage).render(markup, theme, options);
|
||||
}
|
||||
|
||||
public async allAssets(markupLanguage: MarkupLanguage, theme: any) {
|
||||
return this.renderer(markupLanguage).allAssets(theme);
|
||||
public async allAssets(markupLanguage: MarkupLanguage, theme: any, noteStyleOptions: NoteStyleOptions = null) {
|
||||
return this.renderer(markupLanguage).allAssets(theme, noteStyleOptions);
|
||||
}
|
||||
}
|
||||
|
@ -5,10 +5,28 @@ import setupLinkify from './MdToHtml/setupLinkify';
|
||||
import validateLinks from './MdToHtml/validateLinks';
|
||||
import { ItemIdToUrlHandler } from './utils';
|
||||
import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml';
|
||||
import { Options as NoteStyleOptions } from './noteStyle';
|
||||
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const md5 = require('md5');
|
||||
|
||||
export interface RenderOptions {
|
||||
contentMaxWidth?: number;
|
||||
bodyOnly?: boolean;
|
||||
splitted?: boolean;
|
||||
externalAssetsOnly?: boolean;
|
||||
postMessageSyntax?: string;
|
||||
highlightedKeywords?: string[];
|
||||
codeTheme?: string;
|
||||
theme?: any;
|
||||
plugins?: Record<string, any>;
|
||||
audioPlayerEnabled?: boolean;
|
||||
videoPlayerEnabled?: boolean;
|
||||
pdfViewerEnabled?: boolean;
|
||||
codeHighlightCacheKey?: string;
|
||||
plainResourceRendering?: boolean;
|
||||
}
|
||||
|
||||
interface RendererRule {
|
||||
install(context: any, ruleOptions: any): any;
|
||||
assets?(theme: any): any;
|
||||
@ -331,7 +349,7 @@ export default class MdToHtml {
|
||||
}
|
||||
|
||||
// This is similar to allProcessedAssets() but used only by the Rich Text editor
|
||||
public async allAssets(theme: any) {
|
||||
public async allAssets(theme: any, noteStyleOptions: NoteStyleOptions = null) {
|
||||
const assets: any = {};
|
||||
for (const key in rules) {
|
||||
if (!this.pluginEnabled(key)) continue;
|
||||
@ -343,7 +361,7 @@ export default class MdToHtml {
|
||||
}
|
||||
|
||||
const processedAssets = this.processPluginAssets(assets);
|
||||
processedAssets.cssStrings.splice(0, 0, noteStyle(theme).join('\n'));
|
||||
processedAssets.cssStrings.splice(0, 0, noteStyle(theme, noteStyleOptions).join('\n'));
|
||||
if (this.customCss_) processedAssets.cssStrings.push(this.customCss_);
|
||||
const output = await this.outputAssetsToExternalAssets_(processedAssets);
|
||||
return output.pluginAssets;
|
||||
@ -376,8 +394,9 @@ export default class MdToHtml {
|
||||
}
|
||||
|
||||
// "theme" is the theme as returned by themeStyle()
|
||||
public async render(body: string, theme: any = null, options: any = null): Promise<RenderResult> {
|
||||
options = Object.assign({}, {
|
||||
public async render(body: string, theme: any = null, options: RenderOptions = null): Promise<RenderResult> {
|
||||
|
||||
options = {
|
||||
// In bodyOnly mode, the rendered Markdown is returned without the wrapper DIV
|
||||
bodyOnly: false,
|
||||
// In splitted mode, the CSS and HTML will be returned in separate properties.
|
||||
@ -395,7 +414,10 @@ export default class MdToHtml {
|
||||
audioPlayerEnabled: this.pluginEnabled('audioPlayer'),
|
||||
videoPlayerEnabled: this.pluginEnabled('videoPlayer'),
|
||||
pdfViewerEnabled: this.pluginEnabled('pdfViewer'),
|
||||
}, options);
|
||||
|
||||
contentMaxWidth: 0,
|
||||
...options,
|
||||
};
|
||||
|
||||
// The "codeHighlightCacheKey" option indicates what set of cached object should be
|
||||
// associated with this particular Markdown body. It is only used to allow us to
|
||||
@ -525,7 +547,9 @@ export default class MdToHtml {
|
||||
|
||||
const renderedBody = markdownIt.render(body, context);
|
||||
|
||||
let cssStrings = noteStyle(options.theme);
|
||||
let cssStrings = noteStyle(options.theme, {
|
||||
contentMaxWidth: options.contentMaxWidth,
|
||||
});
|
||||
|
||||
let output = { ...this.allProcessedAssets(allRules, options.theme, options.codeTheme) };
|
||||
cssStrings = cssStrings.concat(output.cssStrings);
|
||||
|
@ -7,11 +7,28 @@ function formatCssSize(v: any): string {
|
||||
return `${v}px`;
|
||||
}
|
||||
|
||||
export default function(theme: any) {
|
||||
export interface Options {
|
||||
contentMaxWidth?: number;
|
||||
}
|
||||
|
||||
export default function(theme: any, options: Options = null) {
|
||||
options = {
|
||||
contentMaxWidth: 0,
|
||||
...options,
|
||||
};
|
||||
|
||||
theme = theme ? theme : {};
|
||||
|
||||
const fontFamily = '\'Avenir\', \'Arial\', sans-serif';
|
||||
|
||||
const maxWidthCss = options.contentMaxWidth ? `
|
||||
#rendered-md {
|
||||
max-width: ${options.contentMaxWidth}px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
` : '';
|
||||
|
||||
const css =
|
||||
`
|
||||
/* https://necolas.github.io/normalize.css/ */
|
||||
@ -61,6 +78,8 @@ export default function(theme: any) {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
${maxWidthCss}
|
||||
|
||||
/* Remove top padding and margin from first child so that top of rendered text is aligned to top of text editor text */
|
||||
|
||||
#rendered-md > h1:first-child,
|
||||
|
@ -1,3 +1,5 @@
|
||||
// We don't want the tests to fail due to timeout, especially on CI, and certain
|
||||
// tests can take more time since we do integration testing too.
|
||||
jest.setTimeout(30 * 1000);
|
||||
|
||||
process.env.JOPLIN_IS_TESTING = '1';
|
||||
|
11
packages/server/package-lock.json
generated
11
packages/server/package-lock.json
generated
@ -31,6 +31,7 @@
|
||||
"pg": "^8.5.1",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"query-string": "^6.8.3",
|
||||
"rate-limiter-flexible": "^2.2.4",
|
||||
"raw-body": "^2.4.1",
|
||||
"sqlite3": "^4.1.0",
|
||||
"stripe": "^8.150.0",
|
||||
@ -8802,6 +8803,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/rate-limiter-flexible": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.2.4.tgz",
|
||||
"integrity": "sha512-8u4k5b1afuBcfydX0L0l3J2PNjgcuo3zua8plhvIisyDqOBldrCwfSFut/Fj00LAB1nxJYVM9jeszr2rZyDhQw=="
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
|
||||
@ -17770,6 +17776,11 @@
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"dev": true
|
||||
},
|
||||
"rate-limiter-flexible": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-2.2.4.tgz",
|
||||
"integrity": "sha512-8u4k5b1afuBcfydX0L0l3J2PNjgcuo3zua8plhvIisyDqOBldrCwfSFut/Fj00LAB1nxJYVM9jeszr2rZyDhQw=="
|
||||
},
|
||||
"raw-body": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
|
||||
|
@ -4,11 +4,12 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
"start-dev-no-watch": "node dist/app.js --env dev",
|
||||
"devCreateDb": "node dist/app.js --env dev --create-db",
|
||||
"devDropTables": "node dist/app.js --env dev --drop-tables",
|
||||
"devDropDb": "node dist/app.js --env dev --drop-db",
|
||||
"start": "node dist/app.js",
|
||||
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
|
||||
"generateTypes": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-latest --env buildTypes && node dist/tools/generateTypes.js && mv db-buildTypes.sqlite schema.sqlite",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"test": "jest --verbose=false",
|
||||
"test-ci": "npm run test",
|
||||
@ -42,6 +43,7 @@
|
||||
"pg": "^8.5.1",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"query-string": "^6.8.3",
|
||||
"rate-limiter-flexible": "^2.2.4",
|
||||
"raw-body": "^2.4.1",
|
||||
"sqlite3": "^4.1.0",
|
||||
"stripe": "^8.150.0",
|
||||
|
0
packages/server/public/css/index/changes.css
Normal file
0
packages/server/public/css/index/changes.css
Normal file
@ -7,7 +7,7 @@ import { argv } from 'yargs';
|
||||
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
|
||||
import config, { initConfig, runningInDocker, EnvVariables } from './config';
|
||||
import { createDb, dropDb } from './tools/dbTools';
|
||||
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection, sqliteDefaultDir } from './db';
|
||||
import { dropTables, connectDb, disconnectDb, migrateLatest, waitForConnection, sqliteDefaultDir, migrateList, migrateUp, migrateDown } from './db';
|
||||
import { AppContext, Env, KoaNext } from './utils/types';
|
||||
import FsDriverNode from '@joplin/lib/fs-driver-node';
|
||||
import routeHandler from './middleware/routeHandler';
|
||||
@ -205,10 +205,23 @@ async function main() {
|
||||
fs.writeFileSync(pidFile, `${process.pid}`);
|
||||
}
|
||||
|
||||
if (argv.migrateDb) {
|
||||
let runCommandAndExitApp = true;
|
||||
|
||||
if (argv.migrateLatest) {
|
||||
const db = await connectDb(config().database);
|
||||
await migrateDb(db);
|
||||
await migrateLatest(db);
|
||||
await disconnectDb(db);
|
||||
} else if (argv.migrateUp) {
|
||||
const db = await connectDb(config().database);
|
||||
await migrateUp(db);
|
||||
await disconnectDb(db);
|
||||
} else if (argv.migrateDown) {
|
||||
const db = await connectDb(config().database);
|
||||
await migrateDown(db);
|
||||
await disconnectDb(db);
|
||||
} else if (argv.migrateList) {
|
||||
const db = await connectDb(config().database);
|
||||
console.info(await migrateList(db));
|
||||
} else if (argv.dropDb) {
|
||||
await dropDb(config().database, { ignoreIfNotExists: true });
|
||||
} else if (argv.dropTables) {
|
||||
@ -218,6 +231,8 @@ async function main() {
|
||||
} else if (argv.createDb) {
|
||||
await createDb(config().database);
|
||||
} else {
|
||||
runCommandAndExitApp = false;
|
||||
|
||||
appLogger().info(`Starting server v${config().appVersion} (${env}) on port ${config().port} and PID ${process.pid}...`);
|
||||
appLogger().info('Running in Docker:', runningInDocker());
|
||||
appLogger().info('Public base URL:', config().baseUrl);
|
||||
@ -239,7 +254,7 @@ async function main() {
|
||||
await initializeJoplinUtils(config(), ctx.joplinBase.models, ctx.joplinBase.services.mustache);
|
||||
|
||||
appLogger().info('Migrating database...');
|
||||
await migrateDb(ctx.joplinBase.db);
|
||||
await migrateLatest(ctx.joplinBase.db);
|
||||
|
||||
appLogger().info('Starting services...');
|
||||
await startServices(ctx.joplinBase.services);
|
||||
@ -248,6 +263,8 @@ async function main() {
|
||||
|
||||
app.listen(config().port);
|
||||
}
|
||||
|
||||
if (runCommandAndExitApp) process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error: any) => {
|
||||
|
@ -121,14 +121,85 @@ export async function disconnectDb(db: DbConnection) {
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
export async function migrateDb(db: DbConnection) {
|
||||
export async function migrateLatest(db: DbConnection) {
|
||||
await db.migrate.latest({
|
||||
directory: migrationDir,
|
||||
// Disable transactions because the models might open one too
|
||||
disableTransactions: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function migrateUp(db: DbConnection) {
|
||||
await db.migrate.up({
|
||||
directory: migrationDir,
|
||||
});
|
||||
}
|
||||
|
||||
export async function migrateDown(db: DbConnection) {
|
||||
await db.migrate.down({
|
||||
directory: migrationDir,
|
||||
});
|
||||
}
|
||||
|
||||
export async function migrateList(db: DbConnection, asString: boolean = true) {
|
||||
const migrations: any = await db.migrate.list({
|
||||
directory: migrationDir,
|
||||
});
|
||||
|
||||
// The migration array has a rather inconsistent format:
|
||||
//
|
||||
// [
|
||||
// // Done migrations
|
||||
// [
|
||||
// '20210809222118_email_key_fix.js',
|
||||
// '20210814123815_testing.js',
|
||||
// '20210814123816_testing.js'
|
||||
// ],
|
||||
// // Not done migrations
|
||||
// [
|
||||
// {
|
||||
// file: '20210814123817_testing.js',
|
||||
// directory: '/path/to/packages/server/dist/migrations'
|
||||
// }
|
||||
// ]
|
||||
// ]
|
||||
|
||||
if (!asString) return migrations;
|
||||
|
||||
const formatName = (migrationInfo: any) => {
|
||||
const name = migrationInfo.file ? migrationInfo.file : migrationInfo;
|
||||
|
||||
const s = name.split('.');
|
||||
s.pop();
|
||||
return s.join('.');
|
||||
};
|
||||
|
||||
interface Line {
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const output: Line[] = [];
|
||||
|
||||
for (const s of migrations[0]) {
|
||||
output.push({
|
||||
text: formatName(s),
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of migrations[1]) {
|
||||
output.push({
|
||||
text: formatName(s),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
|
||||
output.sort((a, b) => {
|
||||
return a.text < b.text ? -1 : +1;
|
||||
});
|
||||
|
||||
return output.map(l => `${l.done ? '✓' : '✗'} ${l.text}`).join('\n');
|
||||
}
|
||||
|
||||
function allTableNames(): string[] {
|
||||
const tableNames = Object.keys(databaseSchema);
|
||||
tableNames.push('knex_migrations');
|
||||
|
@ -38,6 +38,8 @@ export default async function(ctx: AppContext) {
|
||||
|
||||
const responseFormat = routeResponseFormat(ctx);
|
||||
|
||||
if (error.retryAfterMs) ctx.response.set('Retry-After', Math.ceil(error.retryAfterMs / 1000).toString());
|
||||
|
||||
if (error.code === 'invalidOrigin') {
|
||||
ctx.response.body = error.message;
|
||||
} else if (responseFormat === RouteResponseFormat.Html) {
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { Knex } from 'knex';
|
||||
// import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
try {
|
||||
await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
|
||||
table.dropUnique(['recipient_email', 'key']);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not drop unique constraint - this is not an error.', error);
|
||||
}
|
||||
export async function up(_db: DbConnection): Promise<any> {
|
||||
// try {
|
||||
// await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
|
||||
// table.dropUnique(['recipient_email', 'key']);
|
||||
// });
|
||||
// } catch (error) {
|
||||
// // console.warn('Could not drop unique constraint - this is not an error.', error);
|
||||
// }
|
||||
}
|
||||
|
||||
export async function down(_db: DbConnection): Promise<any> {
|
||||
|
@ -5,12 +5,15 @@ import { ErrorForbidden } from '../../utils/errors';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
import { User } from '../../db';
|
||||
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
|
||||
|
||||
const router = new Router(RouteType.Api);
|
||||
|
||||
router.public = true;
|
||||
|
||||
router.post('api/sessions', async (_path: SubPath, ctx: AppContext) => {
|
||||
await limiterLoginBruteForce(ctx.ip);
|
||||
|
||||
const fields: User = await bodyFields(ctx.req);
|
||||
const user = await ctx.joplin.models.user().login(fields.email, fields.password);
|
||||
if (!user) throw new ErrorForbidden('Invalid username or password');
|
||||
|
@ -6,6 +6,7 @@ import { formParse } from '../../utils/requestUtils';
|
||||
import config from '../../config';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
|
||||
|
||||
function makeView(error: any = null): View {
|
||||
const view = defaultView('login', 'Login');
|
||||
@ -25,6 +26,8 @@ router.get('login', async (_path: SubPath, _ctx: AppContext) => {
|
||||
});
|
||||
|
||||
router.post('login', async (_path: SubPath, ctx: AppContext) => {
|
||||
await limiterLoginBruteForce(ctx.ip);
|
||||
|
||||
try {
|
||||
const body = await formParse(ctx.req);
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { connectDb, disconnectDb, migrateDb } from '../db';
|
||||
import { connectDb, disconnectDb, migrateLatest } from '../db';
|
||||
import * as fs from 'fs-extra';
|
||||
import { DatabaseConfig } from '../utils/types';
|
||||
|
||||
@ -46,7 +46,7 @@ export async function createDb(config: DatabaseConfig, options: CreateDbOptions
|
||||
|
||||
try {
|
||||
const db = await connectDb(config);
|
||||
await migrateDb(db);
|
||||
await migrateLatest(db);
|
||||
await disconnectDb(db);
|
||||
} catch (error) {
|
||||
error.message += `: ${config.name}`;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DbConnection, dropTables, migrateDb } from '../db';
|
||||
import { DbConnection, dropTables, migrateLatest } from '../db';
|
||||
import newModelFactory from '../models/factory';
|
||||
import { AccountType } from '../models/UserModel';
|
||||
import { Config } from '../utils/types';
|
||||
@ -15,7 +15,7 @@ export async function handleDebugCommands(argv: any, db: DbConnection, config: C
|
||||
|
||||
export async function createTestUsers(db: DbConnection, config: Config) {
|
||||
await dropTables(db);
|
||||
await migrateDb(db);
|
||||
await migrateLatest(db);
|
||||
|
||||
const password = 'hunter1hunter2hunter3';
|
||||
const models = newModelFactory(db, config);
|
||||
|
@ -97,6 +97,17 @@ export class ErrorPayloadTooLarge extends ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorTooManyRequests extends ApiError {
|
||||
public static httpCode: number = 429;
|
||||
public retryAfterMs: number = 0;
|
||||
|
||||
public constructor(message: string = null, retryAfterMs: number = 0) {
|
||||
super(message === null ? 'Too Many Requests' : message, ErrorTooManyRequests.httpCode);
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
Object.setPrototypeOf(this, ErrorTooManyRequests.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToString(error: Error): string {
|
||||
const msg: string[] = [];
|
||||
msg.push(error.message ? error.message : 'Unknown error');
|
||||
|
19
packages/server/src/utils/request/limiterLoginBruteForce.ts
Normal file
19
packages/server/src/utils/request/limiterLoginBruteForce.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { ErrorTooManyRequests } from '../errors';
|
||||
|
||||
const limiterSlowBruteByIP = new RateLimiterMemory({
|
||||
points: 3, // Up to 3 requests per IP
|
||||
duration: 30, // Per 30 seconds
|
||||
});
|
||||
|
||||
export default async function(ip: string) {
|
||||
// Tests need to make many requests quickly so we disable it in this case.
|
||||
if (process.env.JOPLIN_IS_TESTING === '1') return;
|
||||
|
||||
try {
|
||||
await limiterSlowBruteByIP.consume(ip);
|
||||
} catch (error) {
|
||||
const result = error as RateLimiterRes;
|
||||
throw new ErrorTooManyRequests(`Too many login attempts. Please try again in ${Math.ceil(result.msBeforeNext / 1000)} seconds.`, result.msBeforeNext);
|
||||
}
|
||||
}
|
@ -106,9 +106,9 @@ async function main() {
|
||||
|
||||
fs.removeSync(`${serverRoot}/db-testing.sqlite`);
|
||||
|
||||
// const migrateCommand = 'NODE_ENV=testing node dist/app.js --migrate-db --env dev';
|
||||
// const migrateCommand = 'NODE_ENV=testing node dist/app.js --migrate-latest --env dev';
|
||||
const clearCommand = 'node dist/app.js --env dev --drop-tables';
|
||||
const migrateCommand = 'node dist/app.js --env dev --migrate-db';
|
||||
const migrateCommand = 'node dist/app.js --env dev --migrate-latest';
|
||||
|
||||
await execCommand(clearCommand);
|
||||
await execCommand(migrateCommand);
|
||||
|
11
packages/server/src/utils/time.test.ts
Normal file
11
packages/server/src/utils/time.test.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Day, Month, Second } from './time';
|
||||
|
||||
describe('time', function() {
|
||||
|
||||
it('should have correct interval durations', async function() {
|
||||
expect(Second).toBe(1000);
|
||||
expect(Day).toBe(86400000);
|
||||
expect(Month).toBe(2592000000);
|
||||
});
|
||||
|
||||
});
|
@ -14,7 +14,7 @@ function initDayJs() {
|
||||
|
||||
initDayJs();
|
||||
|
||||
export const Second = 60 * 1000;
|
||||
export const Second = 1000;
|
||||
export const Minute = 60 * Second;
|
||||
export const Hour = 60 * Minute;
|
||||
export const Day = 24 * Hour;
|
||||
|
@ -16,6 +16,7 @@ async function main() {
|
||||
const argv = require('yargs').argv;
|
||||
if (!argv.tagName) throw new Error('--tag-name not provided');
|
||||
|
||||
const pushImages = !!argv.pushImages;
|
||||
const tagName = argv.tagName;
|
||||
const isPreRelease = getIsPreRelease(tagName);
|
||||
const imageVersion = getVersionFromTag(tagName, isPreRelease);
|
||||
@ -38,6 +39,7 @@ async function main() {
|
||||
console.info(`Running from: ${process.cwd()}`);
|
||||
|
||||
console.info('tagName:', tagName);
|
||||
console.info('pushImages:', pushImages);
|
||||
console.info('imageVersion:', imageVersion);
|
||||
console.info('isPreRelease:', isPreRelease);
|
||||
console.info('Docker tags:', dockerTags.join(', '));
|
||||
@ -45,7 +47,7 @@ async function main() {
|
||||
await execCommand2(`docker build -t "joplin/server:${imageVersion}" ${buildArgs} -f Dockerfile.server .`);
|
||||
for (const tag of dockerTags) {
|
||||
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:${tag}"`);
|
||||
await execCommand2(`docker push joplin/server:${tag}`);
|
||||
if (pushImages) await execCommand2(`docker push joplin/server:${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3153,7 +3153,7 @@ msgstr "titel"
|
||||
|
||||
#: packages/lib/models/Folder.js:39 packages/lib/models/Note.js:37
|
||||
msgid "updated date"
|
||||
msgstr "uppdaterad datum"
|
||||
msgstr "uppdaterat datum"
|
||||
|
||||
#: packages/lib/models/Folder.js:103
|
||||
msgid "Conflicts"
|
||||
@ -3171,7 +3171,7 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Note.js:38
|
||||
msgid "created date"
|
||||
msgstr "skapad datum"
|
||||
msgstr "skapat datum"
|
||||
|
||||
#: packages/lib/models/Note.js:39
|
||||
msgid "custom order"
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user