1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-15 09:04:04 +02:00

Merge branch 'dev' into release-2.7

This commit is contained in:
Laurent Cozic 2022-02-02 19:22:49 +00:00
commit 980190ec09
106 changed files with 1234 additions and 389 deletions

View File

@ -16,7 +16,7 @@ jobs:
days-before-stale: 30
days-before-close: 7
operations-per-run: 1000
exempt-issue-labels: 'good first issue,upstream,backlog,high,medium,spec,cannot reproduce'
exempt-issue-labels: 'good first issue,upstream,backlog,high,medium,spec,cannot reproduce,enhancement'
stale-issue-label: 'stale'
close-issue-message: 'Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, feel free to create a new issue with up-to-date information.'
# Don't process pull requests at all

View File

@ -1,43 +0,0 @@
diff --git a/index.js b/index.js
index 85d89900d5fe575dd0c19430209fb1703b03554e..5fa68cc9a4bd2b21a7188bd263262fd9b1604ac6 100644
--- a/index.js
+++ b/index.js
@@ -145,7 +145,8 @@ module.exports = function multimd_table_plugin(md, options) {
colspan, leftToken,
rowspan, upTokens = [],
tableLines, tgroupLines,
- tag, text, range, r, c, b;
+ tag, text, range, r, c, b, t,
+ blockState;
if (startLine + 2 > endLine) { return false; }
@@ -315,18 +316,26 @@ module.exports = function multimd_table_plugin(md, options) {
/* Multiline. Join the text and feed into markdown-it blockParser. */
if (options.multiline && trToken.meta.multiline && trToken.meta.mbounds) {
- text = [ text.trimRight() ];
+ // Pad the text with empty lines to ensure the line number mapping is correct
+ text = new Array(trToken.map[0]).fill('').concat([ text.trimRight() ]);
for (b = 1; b < trToken.meta.mbounds.length; b++) {
/* Line with N bounds has cells indexed from 0 to N-2 */
if (c > trToken.meta.mbounds[b].length - 2) { continue; }
range = [ trToken.meta.mbounds[b][c] + 1, trToken.meta.mbounds[b][c + 1] ];
text.push(state.src.slice.apply(state.src, range).trimRight());
}
- state.md.block.parse(text.join('\n'), state.md, state.env, state.tokens);
+ blockState = new state.md.block.State(text.join('\n'), state.md, state.env, []);
+ blockState.level = trToken.level + 1;
+ // Start tokenizing from the actual content (trToken.map[0])
+ state.md.block.tokenize(blockState, trToken.map[0], blockState.lineMax);
+ for (t = 0; t < blockState.tokens.length; t++) {
+ state.tokens.push(blockState.tokens[t]);
+ }
} else {
token = state.push('inline', '', 0);
token.content = text.trim();
token.map = trToken.map;
+ token.level = trToken.level + 1;
token.children = [];
}

View File

@ -672,7 +672,7 @@ footer .bottom-links-row p {
.news-page img,
.news-item-page img {
max-width: 650px;
max-width: 100%;
}
/*****************************************************************

View File

@ -18,10 +18,9 @@ There are also a few forks of existing packages under the "fork-*" name.
## Required dependencies
- Install node 16+ - https://nodejs.org/en/
- [Enable yarn](https://yarnpkg.com/getting-started/install): `corepack enable`
- Install Node 16+. On Windows, also install the build tools - https://nodejs.org/en/
- [Enable Yarn](https://yarnpkg.com/getting-started/install): `corepack enable`
- macOS: Install Cocoapods - `brew install cocoapods`. Apple Silicon [may require libvips](https://github.com/laurent22/joplin/pull/5966#issuecomment-1007158597) - `brew install vips`.
- Windows: Install Windows Build Tools - `yarn install -g windows-build-tools --vs2015`
- Linux: Install dependencies - `sudo apt install build-essential libnss3 libsecret-1-dev python rsync`
## Building

View File

@ -1,50 +1,42 @@
FROM node:16-bullseye
### Build stage
FROM node:16-bullseye AS builder
RUN apt-get update \
&& apt-get install -y \
python \
&& rm -rf /var/lib/apt/lists/*
# Download the init tool Tini and make it executable for use in the final image
ADD https://github.com/krallin/tini/releases/download/v0.19.0/tini-static /tini
RUN chmod u+x /tini
# Enables Yarn
RUN corepack enable
RUN echo "Node: $(node --version)" \
&& echo "Npm: $(npm --version)" \
&& echo "Yarn: $(yarn --version)"
RUN echo "Node: $(node --version)"
RUN echo "Npm: $(npm --version)"
RUN echo "Yarn: $(yarn --version)"
ARG user=joplin
WORKDIR /build
RUN useradd --create-home --shell /bin/bash $user
USER $user
ENV NODE_ENV production
ENV RUNNING_IN_DOCKER 1
EXPOSE ${APP_PORT}
WORKDIR /home/$user
RUN mkdir /home/$user/logs \
&& mkdir /home/$user/.yarn
COPY --chown=$user:$user .yarn/patches ./.yarn/patches
COPY --chown=$user:$user .yarn/plugins ./.yarn/plugins
COPY --chown=$user:$user .yarn/releases ./.yarn/releases
COPY --chown=$user:$user package.json .
COPY --chown=$user:$user .yarnrc.yml .
COPY --chown=$user:$user yarn.lock .
COPY --chown=$user:$user gulpfile.js .
COPY --chown=$user:$user tsconfig.json .
COPY --chown=$user:$user packages/turndown ./packages/turndown
COPY --chown=$user:$user packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
COPY --chown=$user:$user packages/fork-htmlparser2 ./packages/fork-htmlparser2
COPY --chown=$user:$user packages/server/package*.json ./packages/server/
COPY --chown=$user:$user packages/fork-sax ./packages/fork-sax
COPY --chown=$user:$user packages/fork-uslug ./packages/fork-uslug
COPY --chown=$user:$user packages/htmlpack ./packages/htmlpack
COPY --chown=$user:$user packages/renderer ./packages/renderer
COPY --chown=$user:$user packages/tools ./packages/tools
COPY --chown=$user:$user packages/lib ./packages/lib
COPY --chown=$user:$user packages/server ./packages/server
COPY .yarn/plugins ./.yarn/plugins
COPY .yarn/releases ./.yarn/releases
COPY package.json .
COPY .yarnrc.yml .
COPY yarn.lock .
COPY gulpfile.js .
COPY tsconfig.json .
COPY packages/turndown ./packages/turndown
COPY packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
COPY packages/fork-htmlparser2 ./packages/fork-htmlparser2
COPY packages/server/package*.json ./packages/server/
COPY packages/fork-sax ./packages/fork-sax
COPY packages/fork-uslug ./packages/fork-uslug
COPY packages/htmlpack ./packages/htmlpack
COPY packages/renderer ./packages/renderer
COPY packages/tools ./packages/tools
COPY packages/lib ./packages/lib
COPY packages/server ./packages/server
# For some reason there's both a .yarn/cache and .yarn/berry/cache that are
# being generated, and both have the same content. Not clear why it does this
@ -59,10 +51,26 @@ RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
&& yarn cache clean \
&& rm -rf .yarn/berry
# Call the command directly, without going via npm:
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#cmd
WORKDIR "/home/$user/packages/server"
CMD [ "node", "dist/app.js" ]
### Final image
FROM node:16-bullseye-slim
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user
USER $user
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
COPY --chown=$user:$user --from=builder /tini /usr/local/bin/tini
ENV NODE_ENV=production
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
# Use Tini to start Joplin Server:
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals
WORKDIR /home/$user/packages/server
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/app.js"]
# Build-time metadata
# https://github.com/opencontainers/image-spec/blob/master/annotations.md

View File

@ -10,6 +10,11 @@ under that directory is licensed under the default license, which is MIT.
* * *
Joplin® is a trademark of Cozic Ltd registered in the European Union, with
filing number 018544315.
* * *
Logo and Icon License
The Joplin logos and icons are copyright (c) Laurent Cozic, all rights reserved,

View File

@ -4,7 +4,7 @@
* * *
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" /> **Joplin** is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown).
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" /> **Joplin**® is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](#markdown).
Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
@ -125,6 +125,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
- Development
- [How to build the apps](https://github.com/laurent22/joplin/blob/dev/BUILD.md)
- [Writing a technical spec](https://github.com/laurent22/joplin/blob/dev/readme/technical_spec.md)
- [End-to-end encryption spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/e2ee.md)
- [Note History spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/history.md)
- [Sync Lock spec](https://github.com/laurent22/joplin/blob/dev/readme/spec/sync_lock.md)
@ -472,24 +473,6 @@ Notes are sorted by "relevance". Currently it means the notes that contain the r
In the desktop application, press <kbd>Ctrl+P</kbd> or <kbd>Cmd+P</kbd> and type a note title or part of its content to jump to it. Or type <kbd>#</kbd> followed by a tag name, or <kbd>@</kbd> followed by a notebook name.
# Privacy
Joplin values your privacy and security by giving you complete control over your information and digital footprint.
Joplin applications do not send any data to any service without your authorisation. Any data that Joplin saves, such as notes or images, are saved to your own device and you are free to delete this data at any time.
Joplin has many modern features, some of which use third-party services. You can disable any or all of these features in the application settings. These features are:
|Feature | Description | Default|
|--------|-------------|--------|
|Auto-update|Joplin periodically connects to GitHub to check for new releases.|Enabled|
|Geo-location|Joplin saves geo-location information in note properties when you create a note.|Enabled|
|Synchronisation|Joplin supports synchronisation of your notes across multiple devices. If you choose to synchronise with a third-party, such as OneDrive, the notes will be sent to your OneDrive account, in which case the third-party privacy policy applies.|Disabled|
Joplin is developed as an open-source application and the source code is freely available online to inspect.
For any question about Joplin privacy, please leave a message on the [Joplin Forum](https://discourse.joplinapp.org/).
# Donations
Donations to Joplin support the development of the project. Developing quality applications mostly takes time, but there are also some expenses, such as digital certificates to sign the applications, app store fees, hosting, etc. Most of all, your donation will make it possible to keep up the current development standard.

View File

@ -78,8 +78,5 @@
"node-gyp": "^8.4.1",
"nodemon": "^2.0.9"
},
"packageManager": "yarn@3.1.1",
"resolutions": {
"markdown-it-multimd-table@4.1.1": "patch:markdown-it-multimd-table@npm:4.1.1#.yarn/patches/markdown-it-multimd-table-npm-4.1.1-47e334d4bd"
}
"packageManager": "yarn@3.1.1"
}

View File

@ -8,6 +8,7 @@ export default function useEditorSearch(CodeMirror: any) {
const [scrollbarMarks, setScrollbarMarks] = useState(null);
const [previousKeywordValue, setPreviousKeywordValue] = useState(null);
const [previousIndex, setPreviousIndex] = useState(null);
const [previousSearchTimestamp, setPreviousSearchTimestamp] = useState(0);
const [overlayTimeout, setOverlayTimeout] = useState(null);
const overlayTimeoutRef = useRef(null);
overlayTimeoutRef.current = overlayTimeout;
@ -51,7 +52,7 @@ export default function useEditorSearch(CodeMirror: any) {
// Highlights the currently active found work
// It's possible to get tricky with this fucntions and just use findNext/findPrev
// but this is fast enough and works more naturally with the current search logic
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean) {
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) {
const cursor = cm.getSearchCursor(searchTerm);
let match: any = null;
@ -64,7 +65,13 @@ export default function useEditorSearch(CodeMirror: any) {
}
if (match) {
if (scrollTo) cm.scrollIntoView(match);
if (scrollTo) {
if (withSelection) {
cm.setSelection(match.from, match.to);
} else {
cm.scrollTo(match);
}
}
return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' });
}
@ -90,7 +97,7 @@ export default function useEditorSearch(CodeMirror: any) {
CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) {
if (!options) {
options = { selectedIndex: 0 };
options = { selectedIndex: 0, searchTimestamp: 0 };
}
clearMarkers();
@ -107,16 +114,15 @@ export default function useEditorSearch(CodeMirror: any) {
const searchTerm = getSearchTerm(keyword);
// We only want to scroll the first keyword into view in the case of a multi keyword search
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex ||
// If there is only one choice, scrollTo should be true. The below is a dummy of nMatches === 1.
options.selectedIndex === 0);
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo);
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
if (match) marks.push(match);
}
setMarkers(marks);
setPreviousIndex(options.selectedIndex);
setPreviousSearchTimestamp(options.searchTimestamp);
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import Logger from '@joplin/lib/Logger';
import { SearchMarkers } from './useSearchMarkers';
const CommandService = require('@joplin/lib/services/CommandService').default;
const logger = Logger.create('useNoteSearchBar');
@ -70,6 +71,7 @@ export default function useNoteSearchBar() {
const onClose = useCallback(() => {
setShowLocalSearch(false);
setLocalSearch(defaultLocalSearch());
void CommandService.instance().execute('focusElementNoteBody');
}, []);
const setResultCount = useCallback((count: number) => {
@ -90,6 +92,7 @@ export default function useNoteSearchBar() {
selectedIndex: localSearch.selectedIndex,
separateWordSearch: false,
searchTimestamp: localSearch.timestamp,
withSelection: true,
},
keywords: [
{

View File

@ -4,6 +4,7 @@ interface SearchMarkersOptions {
searchTimestamp: number;
selectedIndex: number;
separateWordSearch: boolean;
withSelection?: boolean;
}
export interface SearchMarkers {

View File

@ -277,6 +277,9 @@
let restoreAndRefreshTimeoutID_ = null;
let restoreAndRefreshTimeout_ = Date.now();
// If 'noteRenderComplete' message is ongoing, resizing should not trigger a 'percentScroll' messsage.
let noteRenderCompleteMessageIsOngoing_ = false;
// A callback anonymous function invoked when the scroll height changes.
const onRendering = observeRendering((cause, height, heightChanged) => {
if (!alreadyAllImagesLoaded && !scrollmap.isPresent()) {
@ -285,6 +288,7 @@
alreadyAllImagesLoaded = true;
scrollmap.refresh();
restorePercentScroll();
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete');
return;
}
@ -294,7 +298,7 @@
scrollmap.refresh();
restorePercentScroll();
// To ensures Editor's scroll position is synced with Viewer's
ipcProxySendToHost('percentScroll', percentScroll_);
if (!noteRenderCompleteMessageIsOngoing_) ipcProxySendToHost('percentScroll', percentScroll_);
};
const now = Date.now();
if (now < restoreAndRefreshTimeout_) {
@ -345,11 +349,13 @@
if (scrollmap.isPresent()) {
// Now, ready to receive scrollToHash/setPercentScroll from Editor.
noteRenderCompleteMessageIsOngoing_ = true;
ipcProxySendToHost('noteRenderComplete');
}
}
ipc.setPercentScroll = (event) => {
noteRenderCompleteMessageIsOngoing_ = false;
setPercentScroll(event.percent);
}

View File

@ -1 +1 @@
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiNhYmIyYmY7YmFja2dyb3VuZDojMjgyYzM0fS5obGpzLWtleXdvcmQsLmhsanMtb3BlcmF0b3IsLmhsanMtcGF0dGVybi1tYXRjaHtjb2xvcjojZjkyNjcyfS5obGpzLWZ1bmN0aW9uLC5obGpzLXBhdHRlcm4tbWF0Y2ggLmhsanMtY29uc3RydWN0b3J7Y29sb3I6IzYxYWVlZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXN7Y29sb3I6I2E2ZTIyZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXMgLmhsanMtdHlwaW5ne2NvbG9yOiNmZDk3MWZ9LmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGV7Y29sb3I6IzdlNTdjMn0uaGxqcy1jb25zdHJ1Y3Rvcntjb2xvcjojZTJiOTNkfS5obGpzLWNvbnN0cnVjdG9yIC5obGpzLXN0cmluZ3tjb2xvcjojOWNjYzY1fS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2IxOGViMTtmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYXtjb2xvcjojYzY3OGRkfS5obGpzLWRlbGV0aW9uLC5obGpzLW5hbWUsLmhsanMtc2VjdGlvbiwuaGxqcy1zZWxlY3Rvci10YWcsLmhsanMtc3Vic3R7Y29sb3I6I2UwNmM3NX0uaGxqcy1saXRlcmFse2NvbG9yOiM1NmI2YzJ9LmhsanMtYWRkaXRpb24sLmhsanMtYXR0cmlidXRlLC5obGpzLW1ldGEgLmhsanMtc3RyaW5nLC5obGpzLXJlZ2V4cCwuaGxqcy1zdHJpbmd7Y29sb3I6Izk4YzM3OX0uaGxqcy1idWlsdF9pbiwuaGxqcy1jbGFzcyAuaGxqcy10aXRsZSwuaGxqcy10aXRsZS5jbGFzc197Y29sb3I6I2U2YzA3Yn0uaGxqcy1hdHRyLC5obGpzLW51bWJlciwuaGxqcy1zZWxlY3Rvci1hdHRyLC5obGpzLXNlbGVjdG9yLWNsYXNzLC5obGpzLXNlbGVjdG9yLXBzZXVkbywuaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwuaGxqcy10eXBlLC5obGpzLXZhcmlhYmxle2NvbG9yOiNkMTlhNjZ9LmhsanMtYnVsbGV0LC5obGpzLWxpbmssLmhsanMtbWV0YSwuaGxqcy1zZWxlY3Rvci1pZCwuaGxqcy1zeW1ib2wsLmhsanMtdGl0bGV7Y29sb3I6IzYxYWVlZX0uaGxqcy1lbXBoYXNpc3tmb250LXN0eWxlOml0YWxpY30uaGxqcy1zdHJvbmd7Zm9udC13ZWlnaHQ6NzAwfS5obGpzLWxpbmt7dGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZX0=`;
module.exports = `LyoKCkF0b20gT25lIERhcmsgV2l0aCBzdXBwb3J0IGZvciBSZWFzb25NTCBieSBHaWRpIE1vcnJpcywgYmFzZWQgb2ZmIHdvcmsgYnkgRGFuaWVsIEdhbWFnZQoKT3JpZ2luYWwgT25lIERhcmsgU3ludGF4IHRoZW1lIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL2F0b20vb25lLWRhcmstc3ludGF4CgoqLwouaGxqcyB7CiAgY29sb3I6ICNhYmIyYmY7CiAgYmFja2dyb3VuZDogIzI4MmMzNDsKfQouaGxqcy1rZXl3b3JkLCAuaGxqcy1vcGVyYXRvciB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCAuaGxqcy1jb25zdHJ1Y3RvciB7CiAgY29sb3I6ICM2MWFlZWU7Cn0KLmhsanMtZnVuY3Rpb24gewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWZ1bmN0aW9uIC5obGpzLXBhcmFtcyB7CiAgY29sb3I6ICNBNkUyMkU7Cn0KLmhsanMtZnVuY3Rpb24gLmhsanMtcGFyYW1zIC5obGpzLXR5cGluZyB7CiAgY29sb3I6ICNGRDk3MUY7Cn0KLmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGUgewogIGNvbG9yOiAjN2U1N2MyOwp9Ci5obGpzLWNvbnN0cnVjdG9yIHsKICBjb2xvcjogI2UyYjkzZDsKfQouaGxqcy1jb25zdHJ1Y3RvciAuaGxqcy1zdHJpbmcgewogIGNvbG9yOiAjOUNDQzY1Owp9Ci5obGpzLWNvbW1lbnQsIC5obGpzLXF1b3RlIHsKICBjb2xvcjogI2IxOGViMTsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtZG9jdGFnLCAuaGxqcy1mb3JtdWxhIHsKICBjb2xvcjogI2M2NzhkZDsKfQouaGxqcy1zZWN0aW9uLCAuaGxqcy1uYW1lLCAuaGxqcy1zZWxlY3Rvci10YWcsIC5obGpzLWRlbGV0aW9uLCAuaGxqcy1zdWJzdCB7CiAgY29sb3I6ICNlMDZjNzU7Cn0KLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICM1NmI2YzI7Cn0KLmhsanMtc3RyaW5nLCAuaGxqcy1yZWdleHAsIC5obGpzLWFkZGl0aW9uLCAuaGxqcy1hdHRyaWJ1dGUsIC5obGpzLW1ldGEgLmhsanMtc3RyaW5nIHsKICBjb2xvcjogIzk4YzM3OTsKfQouaGxqcy1idWlsdF9pbiwKLmhsanMtdGl0bGUuY2xhc3NfLAouaGxqcy1jbGFzcyAuaGxqcy10aXRsZSB7CiAgY29sb3I6ICNlNmMwN2I7Cn0KLmhsanMtYXR0ciwgLmhsanMtdmFyaWFibGUsIC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLCAuaGxqcy10eXBlLCAuaGxqcy1zZWxlY3Rvci1jbGFzcywgLmhsanMtc2VsZWN0b3ItYXR0ciwgLmhsanMtc2VsZWN0b3ItcHNldWRvLCAuaGxqcy1udW1iZXIgewogIGNvbG9yOiAjZDE5YTY2Owp9Ci5obGpzLXN5bWJvbCwgLmhsanMtYnVsbGV0LCAuaGxqcy1saW5rLCAuaGxqcy1tZXRhLCAuaGxqcy1zZWxlY3Rvci1pZCwgLmhsanMtdGl0bGUgewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWVtcGhhc2lzIHsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQouaGxqcy1saW5rIHsKICB0ZXh0LWRlY29yYXRpb246IHVuZGVybGluZTsKfQo=`;

View File

@ -1 +1 @@
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiMzODNhNDI7YmFja2dyb3VuZDojZmFmYWZhfS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2EwYTFhNztmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYSwuaGxqcy1rZXl3b3Jke2NvbG9yOiNhNjI2YTR9LmhsanMtZGVsZXRpb24sLmhsanMtbmFtZSwuaGxqcy1zZWN0aW9uLC5obGpzLXNlbGVjdG9yLXRhZywuaGxqcy1zdWJzdHtjb2xvcjojZTQ1NjQ5fS5obGpzLWxpdGVyYWx7Y29sb3I6IzAxODRiYn0uaGxqcy1hZGRpdGlvbiwuaGxqcy1hdHRyaWJ1dGUsLmhsanMtbWV0YSAuaGxqcy1zdHJpbmcsLmhsanMtcmVnZXhwLC5obGpzLXN0cmluZ3tjb2xvcjojNTBhMTRmfS5obGpzLWF0dHIsLmhsanMtbnVtYmVyLC5obGpzLXNlbGVjdG9yLWF0dHIsLmhsanMtc2VsZWN0b3ItY2xhc3MsLmhsanMtc2VsZWN0b3ItcHNldWRvLC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLC5obGpzLXR5cGUsLmhsanMtdmFyaWFibGV7Y29sb3I6Izk4NjgwMX0uaGxqcy1idWxsZXQsLmhsanMtbGluaywuaGxqcy1tZXRhLC5obGpzLXNlbGVjdG9yLWlkLC5obGpzLXN5bWJvbCwuaGxqcy10aXRsZXtjb2xvcjojNDA3OGYyfS5obGpzLWJ1aWx0X2luLC5obGpzLWNsYXNzIC5obGpzLXRpdGxlLC5obGpzLXRpdGxlLmNsYXNzX3tjb2xvcjojYzE4NDAxfS5obGpzLWVtcGhhc2lze2ZvbnQtc3R5bGU6aXRhbGljfS5obGpzLXN0cm9uZ3tmb250LXdlaWdodDo3MDB9LmhsanMtbGlua3t0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lfQ==`;
module.exports = `LyoKCkF0b20gT25lIExpZ2h0IGJ5IERhbmllbCBHYW1hZ2UKT3JpZ2luYWwgT25lIExpZ2h0IFN5bnRheCB0aGVtZSBmcm9tIGh0dHBzOi8vZ2l0aHViLmNvbS9hdG9tL29uZS1saWdodC1zeW50YXgKCmJhc2U6ICAgICNmYWZhZmEKbW9uby0xOiAgIzM4M2E0Mgptb25vLTI6ICAjNjg2Yjc3Cm1vbm8tMzogICNhMGExYTcKaHVlLTE6ICAgIzAxODRiYgpodWUtMjogICAjNDA3OGYyCmh1ZS0zOiAgICNhNjI2YTQKaHVlLTQ6ICAgIzUwYTE0ZgpodWUtNTogICAjZTQ1NjQ5Cmh1ZS01LTI6ICNjOTEyNDMKaHVlLTY6ICAgIzk4NjgwMQpodWUtNi0yOiAjYzE4NDAxCgoqLwoKLmhsanMgewogIGNvbG9yOiAjMzgzYTQyOwogIGJhY2tncm91bmQ6ICNmYWZhZmE7Cn0KCi5obGpzLWNvbW1lbnQsCi5obGpzLXF1b3RlIHsKICBjb2xvcjogI2EwYTFhNzsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KCi5obGpzLWRvY3RhZywKLmhsanMta2V5d29yZCwKLmhsanMtZm9ybXVsYSB7CiAgY29sb3I6ICNhNjI2YTQ7Cn0KCi5obGpzLXNlY3Rpb24sCi5obGpzLW5hbWUsCi5obGpzLXNlbGVjdG9yLXRhZywKLmhsanMtZGVsZXRpb24sCi5obGpzLXN1YnN0IHsKICBjb2xvcjogI2U0NTY0OTsKfQoKLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICMwMTg0YmI7Cn0KCi5obGpzLXN0cmluZywKLmhsanMtcmVnZXhwLAouaGxqcy1hZGRpdGlvbiwKLmhsanMtYXR0cmlidXRlLAouaGxqcy1tZXRhIC5obGpzLXN0cmluZyB7CiAgY29sb3I6ICM1MGExNGY7Cn0KCi5obGpzLWF0dHIsCi5obGpzLXZhcmlhYmxlLAouaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwKLmhsanMtdHlwZSwKLmhsanMtc2VsZWN0b3ItY2xhc3MsCi5obGpzLXNlbGVjdG9yLWF0dHIsCi5obGpzLXNlbGVjdG9yLXBzZXVkbywKLmhsanMtbnVtYmVyIHsKICBjb2xvcjogIzk4NjgwMTsKfQoKLmhsanMtc3ltYm9sLAouaGxqcy1idWxsZXQsCi5obGpzLWxpbmssCi5obGpzLW1ldGEsCi5obGpzLXNlbGVjdG9yLWlkLAouaGxqcy10aXRsZSB7CiAgY29sb3I6ICM0MDc4ZjI7Cn0KCi5obGpzLWJ1aWx0X2luLAouaGxqcy10aXRsZS5jbGFzc18sCi5obGpzLWNsYXNzIC5obGpzLXRpdGxlIHsKICBjb2xvcjogI2MxODQwMTsKfQoKLmhsanMtZW1waGFzaXMgewogIGZvbnQtc3R5bGU6IGl0YWxpYzsKfQoKLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQoKLmhsanMtbGluayB7CiAgdGV4dC1kZWNvcmF0aW9uOiB1bmRlcmxpbmU7Cn0K`;

View File

@ -1,5 +1,5 @@
module.exports = {
hash:"dd2315568bb7795f97cee26a47e9b82b", files: {
hash:"ea13a22d0df59339b671f6b5700e2914", files: {
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
module.exports = `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`;
module.exports = `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`;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1,74 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-keyword,.hljs-operator,.hljs-pattern-match{color:#f92672}.hljs-function,.hljs-pattern-match .hljs-constructor{color:#61aeee}.hljs-function .hljs-params{color:#a6e22e}.hljs-function .hljs-params .hljs-typing{color:#fd971f}.hljs-module-access .hljs-module{color:#7e57c2}.hljs-constructor{color:#e2b93d}.hljs-constructor .hljs-string{color:#9ccc65}.hljs-comment,.hljs-quote{color:#b18eb1;font-style:italic}.hljs-doctag,.hljs-formula{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
/*
Atom One Dark With support for ReasonML by Gidi Morris, based off work by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
*/
.hljs {
color: #abb2bf;
background: #282c34;
}
.hljs-keyword, .hljs-operator {
color: #F92672;
}
.hljs-pattern-match {
color: #F92672;
}
.hljs-pattern-match .hljs-constructor {
color: #61aeee;
}
.hljs-function {
color: #61aeee;
}
.hljs-function .hljs-params {
color: #A6E22E;
}
.hljs-function .hljs-params .hljs-typing {
color: #FD971F;
}
.hljs-module-access .hljs-module {
color: #7e57c2;
}
.hljs-constructor {
color: #e2b93d;
}
.hljs-constructor .hljs-string {
color: #9CCC65;
}
.hljs-comment, .hljs-quote {
color: #b18eb1;
font-style: italic;
}
.hljs-doctag, .hljs-formula {
color: #c678dd;
}
.hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst {
color: #e06c75;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string {
color: #98c379;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #e6c07b;
}
.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number {
color: #d19a66;
}
.hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title {
color: #61aeee;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

View File

@ -1 +1,94 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
/*
Atom One Light by Daniel Gamage
Original One Light Syntax theme from https://github.com/atom/one-light-syntax
base: #fafafa
mono-1: #383a42
mono-2: #686b77
mono-3: #a0a1a7
hue-1: #0184bb
hue-2: #4078f2
hue-3: #a626a4
hue-4: #50a14f
hue-5: #e45649
hue-5-2: #c91243
hue-6: #986801
hue-6-2: #c18401
*/
.hljs {
color: #383a42;
background: #fafafa;
}
.hljs-comment,
.hljs-quote {
color: #a0a1a7;
font-style: italic;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #a626a4;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #e45649;
}
.hljs-literal {
color: #0184bb;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string {
color: #50a14f;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-type,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #986801;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id,
.hljs-title {
color: #4078f2;
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #c18401;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -126,6 +126,7 @@ export default function(theme: any, options: Options = null) {
}
h3 {
font-size: 1.1em;
font-weight: bold;
}
h4, h5, h6 {
font-size: 1em;

View File

@ -41,12 +41,12 @@
"markdown-it-footnote": "^3.0.2",
"markdown-it-ins": "^3.0.0",
"markdown-it-mark": "^3.0.0",
"markdown-it-multimd-table": "^4.0.1",
"markdown-it-multimd-table": "^4.1.2",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-it-toc-done-right": "^4.1.0",
"md5": "^2.2.1",
"mermaid": "^8.13.5"
"mermaid": "^8.13.9"
},
"gitHead": "eb4b0e64eab40a51b0895d3a40a9d8c3cb7b1b14"
}

View File

@ -3,7 +3,16 @@
}
html {
font-size: 100%;
font-size: 14px;
}
.is-admin-page div.main-container,
.is-admin-page div.navbar-container {
max-width: none !important;
}
div.navbar-container {
padding: 0 3rem;
}
input.form-control {
@ -14,17 +23,16 @@ input.form-control {
align-items: center;
}
.navbar .logo-container .navbar-appname {
margin-left: 15px;
}
.navbar .logo-text {
font-size: 2.2em;
font-weight: bold;
margin-left: 0.5em;
}
/*
.navbar .logo {
height: 50px;
} */
.navbar .navbar-item img {
max-height: 3em;
}

Binary file not shown.

View File

@ -106,6 +106,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
const supportEmail = env.SUPPORT_EMAIL;
config_ = {
...env,
appVersion: packageJson.version,
appName,
isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'),

View File

@ -25,12 +25,13 @@ describe('db', function() {
const ignoreAllBefore = '20210819165350_user_flags';
// Some migrations produce no changes visible to sql-ts, in particular
// when the migration only adds a constraint or an index. In this case
// we skip the migration. Ideally we should test these too but for now
// that will do.
// when the migration only adds a constraint or an index, or when a
// default is changed. In this case we skip the migration. Ideally we
// should test these too but for now that will do.
const doNoCheckUpgrade = [
'20211030103016_item_owner_name_unique',
'20211111134329_storage_index',
'20220121172409_email_recipient_default',
];
let startProcessing = false;

View File

@ -89,6 +89,13 @@ const defaultEnvValues: EnvVariables = {
STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '',
// ==================================================
// User data deletion
// ==================================================
USER_DATA_AUTO_DELETE_ENABLED: false,
USER_DATA_AUTO_DELETE_AFTER_DAYS: 90,
};
export interface EnvVariables {
@ -138,6 +145,9 @@ export interface EnvVariables {
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
USER_DATA_AUTO_DELETE_ENABLED: boolean;
USER_DATA_AUTO_DELETE_AFTER_DAYS: number;
}
const parseBoolean = (s: string): boolean => {

View File

@ -0,0 +1,20 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
// Email recipient_id was incorrectly set to "0" by default. This migration set
// it to an empty string by default, and update all rows that have "0" as
// recipient_id.
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
table.string('recipient_id', 32).defaultTo('').notNullable().alter();
});
await db('emails').update({ recipient_id: '' }).where('recipient_id', '=', '0');
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
table.string('recipient_id', 32).defaultTo(0).notNullable().alter();
});
}

View File

@ -0,0 +1,54 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
// It's assumed that the input user IDs are disabled.
// The disabled_time will be set to the first flag created_time
export const setUserAccountDisabledTimes = async (db: DbConnection, userIds: string[]) => {
// FailedPaymentFinal = 2,
// SubscriptionCancelled = 5,
// ManuallyDisabled = 6,
// UserDeletionInProgress = 7,
interface UserFlag {
user_id: string;
created_time: number;
}
const flags: UserFlag[] = await db('user_flags')
.select(['user_id', 'created_time'])
.whereIn('user_id', userIds)
.whereIn('type', [2, 5, 6, 7])
.orderBy('created_time', 'asc');
for (const userId of userIds) {
const flag = flags.find(f => f.user_id === userId);
if (!flag) {
console.warn(`Found a disabled account without an associated flag. Setting disabled timestamp to current time: ${userId}`);
}
await db('users')
.update({ disabled_time: flag ? flag.created_time : Date.now() })
.where('id', '=', userId);
}
};
export const disabledUserIds = async (db: DbConnection): Promise<string[]> => {
const users = await db('users').select(['id']).where('enabled', '=', 0);
return users.map(u => u.id);
};
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
table.bigInteger('disabled_time').defaultTo(0).notNullable();
});
const userIds = await disabledUserIds(db);
await setUserAccountDisabledTimes(db, userIds);
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', (table: Knex.CreateTableBuilder) => {
table.dropColumn('disabled_time');
});
}

View File

@ -0,0 +1,17 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('backup_items', (table: Knex.CreateTableBuilder) => {
table.increments('id').unique().primary().notNullable();
table.integer('type').notNullable();
table.text('key', 'mediumtext').notNullable();
table.string('user_id', 32).defaultTo('').notNullable();
table.binary('content').notNullable();
table.bigInteger('created_time').notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('backup_items');
}

View File

@ -0,0 +1,72 @@
import { UserFlagType } from '../../services/database/types';
import { beforeAllDb, afterAllTests, beforeEachDb, createUser, models, db } from '../../utils/testing/testUtils';
import { disabledUserIds, setUserAccountDisabledTimes } from '../20220131185922_account_disabled_timestamp';
describe('20220131185922_account_disabled_timestamp', function() {
beforeAll(async () => {
await beforeAllDb('20220131185922_account_disabled_timestamp');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should set the user account disabled time', async function() {
const user1 = await createUser(1);
const user2 = await createUser(2);
const user3 = await createUser(3);
const user4 = await createUser(4);
jest.useFakeTimers('modern');
// -------------------------------------------------
// User 1
// -------------------------------------------------
const t0 = new Date('2021-12-14').getTime();
jest.setSystemTime(t0);
await models().userFlag().add(user1.id, UserFlagType.AccountOverLimit);
// -------------------------------------------------
// User 2
// -------------------------------------------------
const t1 = new Date('2021-12-15').getTime();
jest.setSystemTime(t1);
await models().userFlag().add(user2.id, UserFlagType.FailedPaymentFinal);
const t2 = new Date('2021-12-16').getTime();
jest.setSystemTime(t2);
await models().userFlag().add(user2.id, UserFlagType.ManuallyDisabled);
// -------------------------------------------------
// User 3
// -------------------------------------------------
const t3 = new Date('2021-12-17').getTime();
jest.setSystemTime(t3);
await models().userFlag().add(user3.id, UserFlagType.SubscriptionCancelled);
const userIds = await disabledUserIds(db());
expect(userIds.sort()).toEqual([user2.id, user3.id].sort());
await setUserAccountDisabledTimes(db(), userIds);
expect((await models().user().load(user1.id)).disabled_time).toBe(0);
expect((await models().user().load(user2.id)).disabled_time).toBe(t1);
expect((await models().user().load(user3.id)).disabled_time).toBe(t3);
expect((await models().user().load(user4.id)).disabled_time).toBe(0);
jest.useRealTimers();
});
});

View File

@ -0,0 +1,29 @@
import { BackupItem, BackupItemType } from '../services/database/types';
import BaseModel from './BaseModel';
export default class BackupItemModel extends BaseModel<BackupItem> {
protected get tableName(): string {
return 'backup_items';
}
protected hasUuid(): boolean {
return false;
}
protected hasUpdatedTime(): boolean {
return false;
}
public async add(type: BackupItemType, key: string, content: any, userId: string = ''): Promise<BackupItem> {
const item: BackupItem = {
user_id: userId,
key,
type,
content,
};
return this.save(item);
}
}

View File

@ -10,6 +10,7 @@ import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/pe
import Logger from '@joplin/lib/Logger';
import dbuuid from '../utils/dbuuid';
import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination';
import { unique } from '../utils/array';
const logger = Logger.create('BaseModel');
@ -165,6 +166,10 @@ export default abstract class BaseModel<T> {
return true;
}
protected hasUpdatedTime(): boolean {
return this.autoTimestampEnabled();
}
protected get hasParentId(): boolean {
return false;
}
@ -314,7 +319,7 @@ export default abstract class BaseModel<T> {
if (isNew) {
(toSave as WithDates).created_time = timestamp;
}
(toSave as WithDates).updated_time = timestamp;
if (this.hasUpdatedTime()) (toSave as WithDates).updated_time = timestamp;
}
if (options.skipValidation !== true) object = await this.validate(object, { isNew: isNew, rules: options.validationRules ? options.validationRules : {} });
@ -339,6 +344,7 @@ export default abstract class BaseModel<T> {
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return [];
ids = unique(ids);
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
}

View File

@ -1,4 +1,5 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models, createUser, expectThrow } from '../utils/testing/testUtils';
import { Day } from '../utils/time';
describe('UserDeletionModel', function() {
@ -143,4 +144,39 @@ describe('UserDeletionModel', function() {
jest.useRealTimers();
});
test('should auto-add users for deletion', async function() {
jest.useFakeTimers('modern');
const t0 = new Date('2022-02-22').getTime();
jest.setSystemTime(t0);
await createUser(1);
const user2 = await createUser(2);
await models().user().save({
id: user2.id,
enabled: 0,
disabled_time: t0,
});
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(0);
const t1 = new Date('2022-05-30').getTime();
jest.setSystemTime(t1);
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
const d = (await models().userDeletion().all())[0];
expect(d.user_id).toBe(user2.id);
// Shouldn't add it again if running autoAdd() again
await models().userDeletion().autoAdd(10, 90 * Day, 3 * Day);
expect(await models().userDeletion().count()).toBe(1);
jest.useRealTimers();
});
});

View File

@ -1,4 +1,4 @@
import { UserDeletion, Uuid } from '../services/database/types';
import { User, UserDeletion, Uuid } from '../services/database/types';
import { errorToString } from '../utils/errors';
import BaseModel from './BaseModel';
@ -7,6 +7,14 @@ export interface AddOptions {
processAccount?: boolean;
}
const defaultAddOptions = () => {
const d: AddOptions = {
processAccount: true,
processData: true,
};
return d;
};
export default class UserDeletionModel extends BaseModel<UserDeletion> {
protected get tableName(): string {
@ -28,8 +36,7 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
public async add(userId: Uuid, scheduledTime: number, options: AddOptions = null): Promise<UserDeletion> {
options = {
processAccount: true,
processData: true,
...defaultAddOptions(),
...options,
};
@ -91,4 +98,26 @@ export default class UserDeletionModel extends BaseModel<UserDeletion> {
.where('id', deletionId);
}
public async autoAdd(maxAutoAddedAccounts: number, ttl: number, scheduledTime: number, options: AddOptions = null): Promise<Uuid[]> {
const cutOffTime = Date.now() - ttl;
const disabledUsers: User[] = await this.db('users')
.select(['users.id'])
.leftJoin('user_deletions', 'users.id', 'user_deletions.user_id')
.where('users.enabled', '=', 0)
.where('users.disabled_time', '<', cutOffTime)
.whereNull('user_deletions.user_id') // Only add users not already in the user_deletions table
.limit(maxAutoAddedAccounts);
const userIds = disabledUsers.map(d => d.id);
await this.withTransaction(async () => {
for (const userId of userIds) {
await this.add(userId, scheduledTime, options);
}
}, 'UserDeletionModel::autoAdd');
return userIds;
}
}

View File

@ -39,4 +39,17 @@ describe('UserFlagModel', function() {
expect(flag.id).not.toBe(differentFlag.id);
});
test('should set the timestamp when disabling an account', async function() {
const { user } = await createUserAndSession(1);
const beforeTime = Date.now();
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
expect((await models().user().load(user.id)).disabled_time).toBeGreaterThanOrEqual(beforeTime);
await models().userFlag().remove(user.id, UserFlagType.FailedPaymentFinal);
expect((await models().user().load(user.id)).disabled_time).toBe(0);
});
});

View File

@ -138,6 +138,10 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
newProps.enabled = 0;
}
if (user.enabled !== newProps.enabled) {
newProps.disabled_time = !newProps.enabled ? Date.now() : 0;
}
if (user.can_upload !== newProps.can_upload || user.enabled !== newProps.enabled) {
await this.models().user().save({
id: userId,

View File

@ -75,6 +75,7 @@ import { Config } from '../utils/types';
import LockModel from './LockModel';
import StorageModel from './StorageModel';
import UserDeletionModel from './UserDeletionModel';
import BackupItemModel from './BackupItemModel';
export type NewModelFactoryHandler = (db: DbConnection)=> Models;
@ -170,6 +171,10 @@ export class Models {
return new UserDeletionModel(this.db_, this.newModelFactory, this.config_);
}
public backupItem() {
return new BackupItemModel(this.db_, this.newModelFactory, this.config_);
}
}
export default function newModelFactory(db: DbConnection, config: Config): Models {

View File

@ -0,0 +1,31 @@
/* eslint-disable import/prefer-default-export */
import config from '../../config';
import { EmailSender } from '../../services/database/types';
interface Participant {
name: string;
email: string;
}
const senders_: Record<number, Participant> = {};
export const senderInfo = (senderId: EmailSender): Participant => {
if (!senders_[senderId]) {
if (senderId === EmailSender.NoReply) {
senders_[senderId] = {
name: config().mailer.noReplyName,
email: config().mailer.noReplyEmail,
};
} else if (senderId === EmailSender.Support) {
senders_[senderId] = {
name: config().supportName,
email: config().supportEmail,
};
} else {
throw new Error(`Invalid sender ID: ${senderId}`);
}
}
return senders_[senderId];
};

View File

@ -0,0 +1,136 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import defaultView from '../../utils/defaultView';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import { adminEmailsUrl, adminEmailUrl, adminUserUrl } from '../../utils/urlUtils';
import { createCsrfTag } from '../../utils/csrf';
import { senderInfo } from '../../models/utils/email';
import { _ } from '@joplin/lib/locale';
import { View } from '../../services/MustacheService';
import { markdownBodyToHtml } from '../../services/email/utils';
const router: Router = new Router(RouteType.Web);
router.get('admin/emails', async (_path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const pagination = makeTablePagination(ctx.query, 'created_time', PaginationOrderDir.DESC);
const page = await models.email().allPaginated(pagination);
const users = await models.user().loadByIds(page.items.map(e => e.recipient_name));
const table: Table = {
baseUrl: adminEmailsUrl(),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
headers: [
{
name: 'id',
label: 'ID',
},
{
name: 'sender_id',
label: 'From',
},
{
name: 'recipient_name',
label: 'To',
},
{
name: 'user_id',
label: 'User',
},
{
name: 'subject',
label: 'Subject',
},
{
name: 'created_time',
label: 'Created',
},
{
name: 'sent_time',
label: 'Sent',
},
{
name: 'error',
label: 'Error',
},
],
rows: page.items.map(d => {
const sender = senderInfo(d.sender_id);
const senderName = sender.name || sender.email || `Sender ${d.sender_id.toString()}`;
let error = '';
if (d.sent_time && !d.sent_success) {
error = d.error ? d.error : '(Unspecified error)';
}
const row: Row = [
{
value: d.id.toString(),
},
{
value: senderName,
url: sender.email ? `mailto:${escape(sender.email)}` : '',
},
{
value: d.recipient_name || d.recipient_email,
url: `mailto:${escape(d.recipient_email)}`,
},
{
value: d.recipient_id ? (users.find(u => u.id === d.recipient_id)?.email || '(not set)') : '-',
url: d.recipient_id ? adminUserUrl(d.recipient_id) : '',
},
{
value: d.subject,
url: adminEmailUrl(d.id),
},
{
value: formatDateTime(d.created_time),
},
{
value: formatDateTime(d.sent_time),
},
{
value: error,
},
];
return row;
}),
};
const view: View = {
...defaultView('admin/emails', _('Emails')),
content: {
emailTable: makeTableView(table),
csrfTag: await createCsrfTag(ctx),
},
};
return view;
});
router.get('admin/emails/:id', async (path: SubPath, ctx: AppContext) => {
const models = ctx.joplin.models;
const email = await models.email().load(path.id);
const view: View = {
...defaultView('admin/email', _('Email')),
content: {
email,
sender: senderInfo(email.sender_id),
bodyHtml: markdownBodyToHtml(email.body),
},
};
return view;
});
export default router;

View File

@ -23,8 +23,6 @@ router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const page = await ctx.joplin.models.userDeletion().allPaginated(pagination);
const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
console.info(page);
const table: Table = {
baseUrl: adminUserDeletionsUrl(),
requestQuery: ctx.query,

View File

@ -13,6 +13,7 @@ import apiShareUsers from './api/share_users';
import apiUsers from './api/users';
import adminDashboard from './admin/dashboard';
import adminEmails from './admin/emails';
import adminTasks from './admin/tasks';
import adminUserDeletions from './admin/user_deletions';
import adminUsers from './admin/users';
@ -49,6 +50,7 @@ const routes: Routers = {
'api/users': apiUsers,
'admin/dashboard': adminDashboard,
'admin/emails': adminEmails,
'admin/tasks': adminTasks,
'admin/user_deletions': adminUserDeletions,
'admin/users': adminUsers,

View File

@ -8,14 +8,10 @@ import { errorToString } from '../utils/errors';
import EmailModel from '../models/EmailModel';
import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils';
import { MailerSecurity } from '../env';
import { senderInfo } from '../models/utils/email';
const logger = Logger.create('EmailService');
interface Participant {
name: string;
email: string;
}
export default class EmailService extends BaseService {
private transport_: any;
@ -23,7 +19,7 @@ export default class EmailService extends BaseService {
private async transport(): Promise<Mail> {
if (!this.transport_) {
try {
if (!this.senderInfo(EmailSender.NoReply).email) {
if (!senderInfo(EmailSender.NoReply).email) {
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
}
@ -58,24 +54,6 @@ export default class EmailService extends BaseService {
return this.transport_;
}
private senderInfo(senderId: EmailSender): Participant {
if (senderId === EmailSender.NoReply) {
return {
name: this.config.mailer.noReplyName,
email: this.config.mailer.noReplyEmail,
};
}
if (senderId === EmailSender.Support) {
return {
name: this.config.supportName,
email: this.config.supportEmail,
};
}
throw new Error(`Invalid sender ID: ${senderId}`);
}
private escapeEmailField(f: string): string {
return f.replace(/[\n\r"<>]/g, '');
}
@ -99,7 +77,7 @@ export default class EmailService extends BaseService {
const transport = await this.transport();
for (const email of emails) {
const sender = this.senderInfo(email.sender_id);
const sender = senderInfo(email.sender_id);
const mailOptions: Mail.Options = {
from: this.formatNameAndEmail(sender.email, sender.name),

View File

@ -9,7 +9,7 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
import MarkdownIt = require('markdown-it');
import { headerAnchor } from '@joplin/renderer';
import { _ } from '@joplin/lib/locale';
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
import { URL } from 'url';
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
@ -151,6 +151,10 @@ export default class MustacheService {
title: _('Tasks'),
url: adminTasksUrl(),
},
{
title: _('Emails'),
url: adminEmailsUrl(),
},
],
},
];
@ -278,10 +282,15 @@ export default class MustacheService {
throw new Error(`Unsupported view extension: ${ext}`);
}
private formatPageName(name: string): string {
return name.replace(/[/\\]/g, '-');
}
public async renderView(view: View, globalParams: GlobalParams = null): Promise<string> {
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
const filePath = await this.viewFilePath(view.path);
const isAdminPage = view.path.startsWith('/admin/');
globalParams = {
...this.defaultLayoutOptions,
@ -289,7 +298,7 @@ export default class MustacheService {
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
isAdminPage: view.path.startsWith('/admin/'),
isAdminPage,
s: {
home: _('Home'),
users: _('Users'),
@ -306,7 +315,7 @@ export default class MustacheService {
const layoutView: any = {
global: globalParams,
pageName: view.name,
pageName: this.formatPageName(view.name),
pageTitle: view.titleOverride ? view.title : `${config().appName} - ${view.title}`,
contentHtml: contentHtml,
cssFiles: cssFiles,

View File

@ -4,6 +4,7 @@ import { Config, Env } from '../utils/types';
import BaseService from './BaseService';
import { Event, EventType } from './database/types';
import { Services } from './types';
import { _ } from '@joplin/lib/locale';
const cron = require('node-cron');
const logger = Logger.create('TaskService');
@ -17,6 +18,7 @@ export enum TaskId {
DeleteExpiredSessions = 6,
CompressOldChanges = 7,
ProcessUserDeletions = 8,
AutoAddDisabledAccountsForDeletion = 9,
}
export enum RunType {
@ -24,6 +26,25 @@ export enum RunType {
Manual = 2,
}
export const taskIdToLabel = (taskId: TaskId): string => {
const strings: Record<TaskId, string> = {
[TaskId.DeleteExpiredTokens]: _('Delete expired tokens'),
[TaskId.UpdateTotalSizes]: _('Update total sizes'),
[TaskId.HandleOversizedAccounts]: _('Process oversized accounts'),
[TaskId.HandleBetaUserEmails]: 'Process beta user emails',
[TaskId.HandleFailedPaymentSubscriptions]: _('Process failed payment subscriptions'),
[TaskId.DeleteExpiredSessions]: _('Delete expired sessions'),
[TaskId.CompressOldChanges]: _('Compress old changes'),
[TaskId.ProcessUserDeletions]: _('Process user deletions'),
[TaskId.AutoAddDisabledAccountsForDeletion]: _('Auto-add disabled accounts for deletion'),
};
const s = strings[taskId];
if (!s) throw new Error(`No such task: ${taskId}`);
return s;
};
const runTypeToString = (runType: RunType) => {
if (runType === RunType.Scheduled) return 'scheduled';
if (runType === RunType.Manual) return 'manual';

View File

@ -2,6 +2,7 @@ import config from '../config';
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
import { afterAllTests, beforeAllDb, beforeEachDb, createNote, createUserAndSession, models } from '../utils/testing/testUtils';
import { Env } from '../utils/types';
import { BackupItemType } from './database/types';
import UserDeletionService from './UserDeletionService';
const newService = () => {
@ -70,6 +71,8 @@ describe('UserDeletionService', function() {
expect(await models().user().count()).toBe(2);
expect(await models().session().count()).toBe(2);
const beforeTime = Date.now();
const service = newService();
await service.processDeletionJob(job, { sleepBetweenOperations: 0 });
@ -78,6 +81,18 @@ describe('UserDeletionService', function() {
const user = (await models().user().all())[0];
expect(user.id).toBe(user2.id);
const backupItems = await models().backupItem().all();
expect(backupItems.length).toBe(1);
const backupItem = backupItems[0];
expect(backupItem.key).toBe(user1.email);
expect(backupItem.type).toBe(BackupItemType.UserAccount);
expect(backupItem.created_time).toBeGreaterThanOrEqual(beforeTime);
const content = JSON.parse(backupItem.content.toString());
expect(content.user.id).toBe(user1.id);
expect(content.user.email).toBe(user1.email);
expect(content.flags.length).toBe(0);
});
test('should not delete notebooks that are not owned', async function() {

View File

@ -1,8 +1,8 @@
import Logger from '@joplin/lib/Logger';
import { Pagination } from '../models/utils/pagination';
import { msleep } from '../utils/time';
import { Day, msleep } from '../utils/time';
import BaseService from './BaseService';
import { UserDeletion, UserFlagType, Uuid } from './database/types';
import { BackupItemType, UserDeletion, UserFlagType, Uuid } from './database/types';
const logger = Logger.create('UserDeletionService');
@ -59,6 +59,21 @@ export default class UserDeletionService extends BaseService {
private async deleteUserAccount(userId: Uuid, _options: DeletionJobOptions = null) {
logger.info(`Deleting user account: ${userId}`);
const user = await this.models.user().load(userId);
if (!user) throw new Error(`No such user: ${userId}`);
const flags = await this.models.userFlag().allByUserId(userId);
await this.models.backupItem().add(
BackupItemType.UserAccount,
user.email,
JSON.stringify({
user,
flags,
}),
userId
);
await this.models.userFlag().add(userId, UserFlagType.UserDeletionInProgress);
await this.models.session().deleteByUserId(userId);
@ -93,6 +108,24 @@ export default class UserDeletionService extends BaseService {
logger.info('Completed user deletion: ', deletion.id);
}
public async autoAddForDeletion() {
const addedUserIds = await this.models.userDeletion().autoAdd(
10,
this.config.USER_DATA_AUTO_DELETE_AFTER_DAYS * Day,
3 * Day,
{
processAccount: true,
processData: true,
}
);
if (addedUserIds.length) {
logger.info(`autoAddForDeletion: Queued ${addedUserIds.length} users for deletions: ${addedUserIds.join(', ')}`);
} else {
logger.info('autoAddForDeletion: No users were queued for deletion');
}
}
public async processNextDeletionJob() {
const deletion = await this.models.userDeletion().next();
if (!deletion) return;

View File

@ -33,6 +33,10 @@ export enum EventType {
TaskCompleted = 2,
}
export enum BackupItemType {
UserAccount = 1,
}
export enum UserFlagType {
FailedPaymentWarning = 1,
FailedPaymentFinal = 2,
@ -87,6 +91,10 @@ export interface WithDates {
created_time?: number;
}
export interface WithCreatedDate {
created_time?: number;
}
export interface WithUuid {
id?: Uuid;
}
@ -186,20 +194,6 @@ export interface Change extends WithDates, WithUuid {
user_id?: Uuid;
}
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
recipient_email?: string;
recipient_id?: Uuid;
sender_id?: EmailSender;
subject?: string;
body?: string;
sent_time?: number;
sent_success?: number;
error?: string;
key?: string;
}
export interface Token extends WithDates {
id?: number;
value?: string;
@ -233,6 +227,7 @@ export interface User extends WithDates, WithUuid {
max_total_item_size?: number | null;
total_item_size?: number;
enabled?: number;
disabled_time?: number;
}
export interface UserFlag extends WithDates {
@ -282,6 +277,28 @@ export interface UserDeletion extends WithDates {
error?: string;
}
export interface Email extends WithDates {
id?: number;
recipient_name?: string;
recipient_email?: string;
recipient_id?: Uuid;
sender_id?: EmailSender;
subject?: string;
body?: string;
sent_time?: number;
sent_success?: number;
error?: string;
key?: string;
}
export interface BackupItem extends WithCreatedDate {
id?: number;
type?: number;
key?: string;
user_id?: Uuid;
content?: Buffer;
}
export const databaseSchema: DatabaseTables = {
sessions: {
id: { type: 'string' },
@ -374,21 +391,6 @@ export const databaseSchema: DatabaseTables = {
previous_item: { type: 'string' },
user_id: { type: 'string' },
},
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },
recipient_email: { type: 'string' },
recipient_id: { type: 'string' },
sender_id: { type: 'number' },
subject: { type: 'string' },
body: { type: 'string' },
sent_time: { type: 'string' },
sent_success: { type: 'number' },
error: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
key: { type: 'string' },
},
tokens: {
id: { type: 'number' },
value: { type: 'string' },
@ -425,6 +427,7 @@ export const databaseSchema: DatabaseTables = {
max_total_item_size: { type: 'string' },
total_item_size: { type: 'string' },
enabled: { type: 'number' },
disabled_time: { type: 'string' },
},
user_flags: {
id: { type: 'number' },
@ -476,5 +479,28 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
emails: {
id: { type: 'number' },
recipient_name: { type: 'string' },
recipient_email: { type: 'string' },
recipient_id: { type: 'string' },
sender_id: { type: 'number' },
subject: { type: 'string' },
body: { type: 'string' },
sent_time: { type: 'string' },
sent_success: { type: 'number' },
error: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
key: { type: 'string' },
},
backup_items: {
id: { type: 'number' },
type: { type: 'number' },
key: { type: 'string' },
user_id: { type: 'string' },
content: { type: 'any' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@ -36,6 +36,7 @@ const config = {
'main.users': 'WithDates, WithUuid',
'main.events': 'WithUuid',
'main.user_deletions': 'WithDates',
'main.backup_items': 'WithCreatedDate',
},
};
@ -62,6 +63,8 @@ const propertyTypes: Record<string, string> = {
'user_deletions.start_time': 'number',
'user_deletions.end_time': 'number',
'user_deletions.scheduled_time': 'number',
'users.disabled_time': 'number',
'backup_items.content': 'Buffer',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {
@ -92,6 +95,10 @@ function createTypeString(table: any) {
if (['created_time', 'updated_time'].includes(name)) continue;
}
if (table.extends && table.extends.indexOf('WithCreatedDate') >= 0) {
if (['created_time'].includes(name)) continue;
}
if (table.extends && table.extends.indexOf('WithUuid') >= 0) {
if (['id'].includes(name)) continue;
}

View File

@ -1,5 +1,5 @@
import { Models } from '../models/factory';
import TaskService, { Task, TaskId } from '../services/TaskService';
import TaskService, { Task, TaskId, taskIdToLabel } from '../services/TaskService';
import { Services } from '../services/types';
import { Config, Env } from './types';
@ -9,28 +9,28 @@ export default function(env: Env, models: Models, config: Config, services: Serv
let tasks: Task[] = [
{
id: TaskId.DeleteExpiredTokens,
description: 'Delete expired tokens',
description: taskIdToLabel(TaskId.DeleteExpiredTokens),
schedule: '0 */6 * * *',
run: (models: Models) => models.token().deleteExpiredTokens(),
},
{
id: TaskId.UpdateTotalSizes,
description: 'Update total sizes',
description: taskIdToLabel(TaskId.UpdateTotalSizes),
schedule: '0 * * * *',
run: (models: Models) => models.item().updateTotalSizes(),
},
{
id: TaskId.CompressOldChanges,
description: 'Compress old changes',
description: taskIdToLabel(TaskId.CompressOldChanges),
schedule: '0 0 */2 * *',
run: (models: Models) => models.change().compressOldChanges(),
},
{
id: TaskId.ProcessUserDeletions,
description: 'Process user deletions',
description: taskIdToLabel(TaskId.ProcessUserDeletions),
schedule: '0 */6 * * *',
run: (_models: Models, services: Services) => services.userDeletion.runMaintenance(),
},
@ -41,30 +41,44 @@ export default function(env: Env, models: Models, config: Config, services: Serv
// the UpdateTotalSizes task being run.
{
id: TaskId.HandleOversizedAccounts,
description: 'Process oversized accounts',
description: taskIdToLabel(TaskId.HandleOversizedAccounts),
schedule: '30 */2 * * *',
run: (models: Models) => models.user().handleOversizedAccounts(),
},
// This should be enabled eventually. As of version 2.5
// (2021-11-08T11:07:11Z) all Joplin clients support handling of expired
// sessions, however we don't know how many people have Joplin 2.5+ so
// be safe we don't enable it just yet.
// {
// id: TaskId.DeleteExpiredSessions,
// description: 'Delete expired sessions',
// description: taskIdToLabel(TaskId.DeleteExpiredSessions),
// schedule: '0 */6 * * *',
// run: (models: Models) => models.session().deleteExpiredSessions(),
// },
];
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
tasks.push({
id: TaskId.AutoAddDisabledAccountsForDeletion,
description: taskIdToLabel(TaskId.AutoAddDisabledAccountsForDeletion),
schedule: '0 14 * * *',
run: (_models: Models, services: Services) => services.userDeletion.autoAddForDeletion(),
});
}
if (config.isJoplinCloud) {
tasks = tasks.concat([
{
id: TaskId.HandleBetaUserEmails,
description: 'Process beta user emails',
description: taskIdToLabel(TaskId.HandleBetaUserEmails),
schedule: '0 12 * * *',
run: (models: Models) => models.user().handleBetaUserEmails(),
},
{
id: TaskId.HandleFailedPaymentSubscriptions,
description: 'Process failed payment subscriptions',
description: taskIdToLabel(TaskId.HandleFailedPaymentSubscriptions),
schedule: '0 13 * * *',
run: (models: Models) => models.user().handleFailedPaymentSubscriptions(),
},

View File

@ -7,7 +7,7 @@ import { Account } from '../models/UserModel';
import { Services } from '../services/types';
import { Routers } from './routeUtils';
import { DbConnection } from '../db';
import { MailerSecurity } from '../env';
import { EnvVariables, MailerSecurity } from '../env';
export enum Env {
Dev = 'dev',
@ -130,7 +130,7 @@ export interface StorageDriverConfig {
bucket?: string;
}
export interface Config {
export interface Config extends EnvVariables {
appVersion: string;
appName: string;
env: Env;

View File

@ -85,3 +85,11 @@ export function adminUserUrl(userId: string) {
export function adminTasksUrl() {
return `${config().adminBaseUrl}/tasks`;
}
export function adminEmailsUrl() {
return `${config().adminBaseUrl}/emails`;
}
export function adminEmailUrl(id: number) {
return `${config().adminBaseUrl}/emails/${id}`;
}

View File

@ -0,0 +1,15 @@
<div class="block">
<strong>Subject: </strong> {{email.subject}}<br/>
<strong>From: </strong> {{sender.name}} &lt;{{sender.email}}&gt; (Sender ID: {{email.sender_id}})<br/>
<strong>To: </strong> {{email.recipient_name}} &lt;{{email.recipient_email}}&gt;{{#email.recipient_id}} (<a href="{{{global.baseUrl}}}/admin/users/{{email.recipient_id}}">User</a>){{/email.recipient_id}}
</div>
<hr/>
<div class="content">
{{{bodyHtml}}}
</div>
<hr/>
<pre class="block">{{email.body}}</pre>

View File

@ -0,0 +1,7 @@
<form method='POST' action="{{postUrl}}">
{{{csrfTag}}}
{{#emailTable}}
{{>table}}
{{/emailTable}}
</form>

View File

@ -26,7 +26,7 @@ We offer a 14 days trial when the subscription starts so that you can evaluate t
## How can I cancel my account?
Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". We do not cancel accounts over email as we cannot verify your identity.
Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Manage subscription". Your subscription will be cancelled and you will not be charged on what would have been the next billing period. Please note that we do not cancel accounts over email as we cannot verify your identity, however we can provide assistance if there is an issue.
## Further information

View File

@ -19,10 +19,10 @@
<script src="{{{.}}}"></script>
{{/jsFiles}}
</head>
<body class="page-{{{pageName}}}">
<body class="page-{{{pageName}}} {{#global.isAdminPage}}is-admin-page{{/global.isAdminPage}}">
{{> navbar}}
<main class="main">
<div class="container">
<div class="container main-container">
{{> notifications}}
{{#global.isAdminPage}}

View File

@ -1,23 +1,29 @@
{{#navbar}}
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="container">
<div class="container navbar-container">
<div class="navbar-brand logo-container">
<a class="navbar-item" href="{{{global.baseUrl}}}">
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
{{^global.owner}}
<span class="navbar-appname">{{global.appName}}</span>
{{/global.owner}}
</a>
</div>
{{#global.owner}}
<div class="navbar-menu is-active">
{{#global.owner}}
<div class="navbar-start">
{{#global.navbarMenu}}
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i>&nbsp;&nbsp;{{/icon}}{{title}}</a>
{{/global.navbarMenu}}
</div>
{{/global.owner}}
<div class="navbar-end">
{{#global.isJoplinCloud}}
<a class="navbar-item" href="{{{global.baseUrl}}}/help">{{global.s.help}}</a>
{{/global.isJoplinCloud}}
{{#global.owner}}
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-dark">{{global.s.logout}}</button>
@ -34,17 +40,9 @@
</form>
</div>
{{/global.impersonatorAdminSessionId}}
</div>
</div>
{{/global.owner}}
{{^global.owner}}
<div class="navbar-menu is-active">
<div class="navbar-start">
<span class="navbar-item">{{global.appName}}</span>
</div>
</div>
{{/global.owner}}
</div>
</nav>
{{/navbar}}

View File

@ -15,6 +15,8 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.3\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:565
msgid "- Camera: to allow taking a picture and attaching it to a note."
@ -266,11 +268,11 @@ msgstr "Tilføj til ordbog"
#: packages/server/src/services/MustacheService.ts:182
#: packages/server/src/services/MustacheService.ts:301
msgid "Admin"
msgstr ""
msgstr "Admin"
#: packages/server/src/routes/admin/dashboard.ts:10
msgid "Admin dashboard"
msgstr ""
msgstr "Kontrolpanel til administration"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:189
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:147
@ -398,9 +400,8 @@ msgid "Auto-pair braces, parenthesis, quotations, etc."
msgstr "Auto-par klammer, parenteser, citater, etc."
#: packages/lib/models/Setting.ts:1193
#, fuzzy
msgid "Automatically check for updates"
msgstr "Tjek om der er opdateringer.."
msgstr "Tjek automatisk efter opdateringer"
#: packages/lib/models/Setting.ts:770
msgid "Automatically switch theme to match system theme"
@ -764,9 +765,8 @@ msgid "Copy external link"
msgstr "Kopiér eksternt link"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:134
#, fuzzy
msgid "Copy image"
msgstr "Kopier token"
msgstr "Kopier billede"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:172
msgid "Copy Link Address"
@ -860,14 +860,12 @@ msgid "Create a notebook"
msgstr "Opret en notesbog"
#: packages/app-desktop/gui/EditFolderDialog/Dialog.tsx:128
#, fuzzy
msgid "Create notebook"
msgstr "Opret en notesbog"
msgstr "Opret notesbog"
#: packages/server/src/routes/admin/users.ts:171
#, fuzzy
msgid "Create user"
msgstr "Oprettet: %s"
msgstr "Opret bruger"
#: packages/app-desktop/gui/NotePropertiesDialog.min.js:29
msgid "Created"
@ -959,7 +957,7 @@ msgstr "Mørkt"
#: packages/server/src/services/MustacheService.ts:139
msgid "Dashboard"
msgstr ""
msgstr "Kontrolpanel"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:625
msgid "Database v%s"
@ -2214,7 +2212,7 @@ msgstr "Log ud"
#: packages/server/src/services/MustacheService.ts:178
msgid "Logs"
msgstr ""
msgstr "Logfiler"
#: packages/app-desktop/gui/MenuBar.tsx:710
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
@ -2873,7 +2871,7 @@ msgstr "Privatlivspolitik"
#: packages/server/src/routes/admin/users.ts:168
msgid "Profile"
msgstr ""
msgstr "Profil"
#: packages/lib/versionInfo.ts:26
msgid "Profile Version: %s"
@ -4157,9 +4155,8 @@ msgstr "Opdater"
#: packages/server/src/routes/admin/users.ts:171
#: packages/server/src/routes/index/users.ts:89
#, fuzzy
msgid "Update profile"
msgstr "Eksporter profil"
msgstr "Opdater profil"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:208
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:209
@ -4267,7 +4264,7 @@ msgstr ""
#: packages/server/src/services/MustacheService.ts:147
msgid "User deletions"
msgstr ""
msgstr "Brugersletninger"
#: packages/server/src/routes/admin/users.ts:107
#: packages/server/src/services/MustacheService.ts:143

View File

@ -1981,7 +1981,7 @@ msgstr "Zainstaluj z pliku"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:193
msgid "Installed"
msgstr "Zainstwalony"
msgstr "Zainstalowany"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:192
msgid "Installing..."

Some files were not shown because too many files have changed in this diff Show More