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:
commit
980190ec09
2
.github/workflows/close-stale-issues.yml
vendored
2
.github/workflows/close-stale-issues.yml
vendored
@ -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
|
||||
|
@ -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 = [];
|
||||
}
|
||||
|
@ -672,7 +672,7 @@ footer .bottom-links-row p {
|
||||
|
||||
.news-page img,
|
||||
.news-item-page img {
|
||||
max-width: 650px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/*****************************************************************
|
||||
|
5
BUILD.md
5
BUILD.md
@ -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
|
||||
|
@ -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
|
||||
|
5
LICENSE
5
LICENSE
@ -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,
|
||||
|
21
README.md
21
README.md
@ -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.
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ interface SearchMarkersOptions {
|
||||
searchTimestamp: number;
|
||||
selectedIndex: number;
|
||||
separateWordSearch: boolean;
|
||||
withSelection?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchMarkers {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -1 +1 @@
|
||||
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiNhYmIyYmY7YmFja2dyb3VuZDojMjgyYzM0fS5obGpzLWtleXdvcmQsLmhsanMtb3BlcmF0b3IsLmhsanMtcGF0dGVybi1tYXRjaHtjb2xvcjojZjkyNjcyfS5obGpzLWZ1bmN0aW9uLC5obGpzLXBhdHRlcm4tbWF0Y2ggLmhsanMtY29uc3RydWN0b3J7Y29sb3I6IzYxYWVlZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXN7Y29sb3I6I2E2ZTIyZX0uaGxqcy1mdW5jdGlvbiAuaGxqcy1wYXJhbXMgLmhsanMtdHlwaW5ne2NvbG9yOiNmZDk3MWZ9LmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGV7Y29sb3I6IzdlNTdjMn0uaGxqcy1jb25zdHJ1Y3Rvcntjb2xvcjojZTJiOTNkfS5obGpzLWNvbnN0cnVjdG9yIC5obGpzLXN0cmluZ3tjb2xvcjojOWNjYzY1fS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2IxOGViMTtmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYXtjb2xvcjojYzY3OGRkfS5obGpzLWRlbGV0aW9uLC5obGpzLW5hbWUsLmhsanMtc2VjdGlvbiwuaGxqcy1zZWxlY3Rvci10YWcsLmhsanMtc3Vic3R7Y29sb3I6I2UwNmM3NX0uaGxqcy1saXRlcmFse2NvbG9yOiM1NmI2YzJ9LmhsanMtYWRkaXRpb24sLmhsanMtYXR0cmlidXRlLC5obGpzLW1ldGEgLmhsanMtc3RyaW5nLC5obGpzLXJlZ2V4cCwuaGxqcy1zdHJpbmd7Y29sb3I6Izk4YzM3OX0uaGxqcy1idWlsdF9pbiwuaGxqcy1jbGFzcyAuaGxqcy10aXRsZSwuaGxqcy10aXRsZS5jbGFzc197Y29sb3I6I2U2YzA3Yn0uaGxqcy1hdHRyLC5obGpzLW51bWJlciwuaGxqcy1zZWxlY3Rvci1hdHRyLC5obGpzLXNlbGVjdG9yLWNsYXNzLC5obGpzLXNlbGVjdG9yLXBzZXVkbywuaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwuaGxqcy10eXBlLC5obGpzLXZhcmlhYmxle2NvbG9yOiNkMTlhNjZ9LmhsanMtYnVsbGV0LC5obGpzLWxpbmssLmhsanMtbWV0YSwuaGxqcy1zZWxlY3Rvci1pZCwuaGxqcy1zeW1ib2wsLmhsanMtdGl0bGV7Y29sb3I6IzYxYWVlZX0uaGxqcy1lbXBoYXNpc3tmb250LXN0eWxlOml0YWxpY30uaGxqcy1zdHJvbmd7Zm9udC13ZWlnaHQ6NzAwfS5obGpzLWxpbmt7dGV4dC1kZWNvcmF0aW9uOnVuZGVybGluZX0=`;
|
||||
module.exports = `LyoKCkF0b20gT25lIERhcmsgV2l0aCBzdXBwb3J0IGZvciBSZWFzb25NTCBieSBHaWRpIE1vcnJpcywgYmFzZWQgb2ZmIHdvcmsgYnkgRGFuaWVsIEdhbWFnZQoKT3JpZ2luYWwgT25lIERhcmsgU3ludGF4IHRoZW1lIGZyb20gaHR0cHM6Ly9naXRodWIuY29tL2F0b20vb25lLWRhcmstc3ludGF4CgoqLwouaGxqcyB7CiAgY29sb3I6ICNhYmIyYmY7CiAgYmFja2dyb3VuZDogIzI4MmMzNDsKfQouaGxqcy1rZXl3b3JkLCAuaGxqcy1vcGVyYXRvciB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCB7CiAgY29sb3I6ICNGOTI2NzI7Cn0KLmhsanMtcGF0dGVybi1tYXRjaCAuaGxqcy1jb25zdHJ1Y3RvciB7CiAgY29sb3I6ICM2MWFlZWU7Cn0KLmhsanMtZnVuY3Rpb24gewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWZ1bmN0aW9uIC5obGpzLXBhcmFtcyB7CiAgY29sb3I6ICNBNkUyMkU7Cn0KLmhsanMtZnVuY3Rpb24gLmhsanMtcGFyYW1zIC5obGpzLXR5cGluZyB7CiAgY29sb3I6ICNGRDk3MUY7Cn0KLmhsanMtbW9kdWxlLWFjY2VzcyAuaGxqcy1tb2R1bGUgewogIGNvbG9yOiAjN2U1N2MyOwp9Ci5obGpzLWNvbnN0cnVjdG9yIHsKICBjb2xvcjogI2UyYjkzZDsKfQouaGxqcy1jb25zdHJ1Y3RvciAuaGxqcy1zdHJpbmcgewogIGNvbG9yOiAjOUNDQzY1Owp9Ci5obGpzLWNvbW1lbnQsIC5obGpzLXF1b3RlIHsKICBjb2xvcjogI2IxOGViMTsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtZG9jdGFnLCAuaGxqcy1mb3JtdWxhIHsKICBjb2xvcjogI2M2NzhkZDsKfQouaGxqcy1zZWN0aW9uLCAuaGxqcy1uYW1lLCAuaGxqcy1zZWxlY3Rvci10YWcsIC5obGpzLWRlbGV0aW9uLCAuaGxqcy1zdWJzdCB7CiAgY29sb3I6ICNlMDZjNzU7Cn0KLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICM1NmI2YzI7Cn0KLmhsanMtc3RyaW5nLCAuaGxqcy1yZWdleHAsIC5obGpzLWFkZGl0aW9uLCAuaGxqcy1hdHRyaWJ1dGUsIC5obGpzLW1ldGEgLmhsanMtc3RyaW5nIHsKICBjb2xvcjogIzk4YzM3OTsKfQouaGxqcy1idWlsdF9pbiwKLmhsanMtdGl0bGUuY2xhc3NfLAouaGxqcy1jbGFzcyAuaGxqcy10aXRsZSB7CiAgY29sb3I6ICNlNmMwN2I7Cn0KLmhsanMtYXR0ciwgLmhsanMtdmFyaWFibGUsIC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLCAuaGxqcy10eXBlLCAuaGxqcy1zZWxlY3Rvci1jbGFzcywgLmhsanMtc2VsZWN0b3ItYXR0ciwgLmhsanMtc2VsZWN0b3ItcHNldWRvLCAuaGxqcy1udW1iZXIgewogIGNvbG9yOiAjZDE5YTY2Owp9Ci5obGpzLXN5bWJvbCwgLmhsanMtYnVsbGV0LCAuaGxqcy1saW5rLCAuaGxqcy1tZXRhLCAuaGxqcy1zZWxlY3Rvci1pZCwgLmhsanMtdGl0bGUgewogIGNvbG9yOiAjNjFhZWVlOwp9Ci5obGpzLWVtcGhhc2lzIHsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQouaGxqcy1saW5rIHsKICB0ZXh0LWRlY29yYXRpb246IHVuZGVybGluZTsKfQo=`;
|
@ -1 +1 @@
|
||||
module.exports = `cHJlIGNvZGUuaGxqc3tkaXNwbGF5OmJsb2NrO292ZXJmbG93LXg6YXV0bztwYWRkaW5nOjFlbX1jb2RlLmhsanN7cGFkZGluZzozcHggNXB4fS5obGpze2NvbG9yOiMzODNhNDI7YmFja2dyb3VuZDojZmFmYWZhfS5obGpzLWNvbW1lbnQsLmhsanMtcXVvdGV7Y29sb3I6I2EwYTFhNztmb250LXN0eWxlOml0YWxpY30uaGxqcy1kb2N0YWcsLmhsanMtZm9ybXVsYSwuaGxqcy1rZXl3b3Jke2NvbG9yOiNhNjI2YTR9LmhsanMtZGVsZXRpb24sLmhsanMtbmFtZSwuaGxqcy1zZWN0aW9uLC5obGpzLXNlbGVjdG9yLXRhZywuaGxqcy1zdWJzdHtjb2xvcjojZTQ1NjQ5fS5obGpzLWxpdGVyYWx7Y29sb3I6IzAxODRiYn0uaGxqcy1hZGRpdGlvbiwuaGxqcy1hdHRyaWJ1dGUsLmhsanMtbWV0YSAuaGxqcy1zdHJpbmcsLmhsanMtcmVnZXhwLC5obGpzLXN0cmluZ3tjb2xvcjojNTBhMTRmfS5obGpzLWF0dHIsLmhsanMtbnVtYmVyLC5obGpzLXNlbGVjdG9yLWF0dHIsLmhsanMtc2VsZWN0b3ItY2xhc3MsLmhsanMtc2VsZWN0b3ItcHNldWRvLC5obGpzLXRlbXBsYXRlLXZhcmlhYmxlLC5obGpzLXR5cGUsLmhsanMtdmFyaWFibGV7Y29sb3I6Izk4NjgwMX0uaGxqcy1idWxsZXQsLmhsanMtbGluaywuaGxqcy1tZXRhLC5obGpzLXNlbGVjdG9yLWlkLC5obGpzLXN5bWJvbCwuaGxqcy10aXRsZXtjb2xvcjojNDA3OGYyfS5obGpzLWJ1aWx0X2luLC5obGpzLWNsYXNzIC5obGpzLXRpdGxlLC5obGpzLXRpdGxlLmNsYXNzX3tjb2xvcjojYzE4NDAxfS5obGpzLWVtcGhhc2lze2ZvbnQtc3R5bGU6aXRhbGljfS5obGpzLXN0cm9uZ3tmb250LXdlaWdodDo3MDB9LmhsanMtbGlua3t0ZXh0LWRlY29yYXRpb246dW5kZXJsaW5lfQ==`;
|
||||
module.exports = `LyoKCkF0b20gT25lIExpZ2h0IGJ5IERhbmllbCBHYW1hZ2UKT3JpZ2luYWwgT25lIExpZ2h0IFN5bnRheCB0aGVtZSBmcm9tIGh0dHBzOi8vZ2l0aHViLmNvbS9hdG9tL29uZS1saWdodC1zeW50YXgKCmJhc2U6ICAgICNmYWZhZmEKbW9uby0xOiAgIzM4M2E0Mgptb25vLTI6ICAjNjg2Yjc3Cm1vbm8tMzogICNhMGExYTcKaHVlLTE6ICAgIzAxODRiYgpodWUtMjogICAjNDA3OGYyCmh1ZS0zOiAgICNhNjI2YTQKaHVlLTQ6ICAgIzUwYTE0ZgpodWUtNTogICAjZTQ1NjQ5Cmh1ZS01LTI6ICNjOTEyNDMKaHVlLTY6ICAgIzk4NjgwMQpodWUtNi0yOiAjYzE4NDAxCgoqLwoKLmhsanMgewogIGNvbG9yOiAjMzgzYTQyOwogIGJhY2tncm91bmQ6ICNmYWZhZmE7Cn0KCi5obGpzLWNvbW1lbnQsCi5obGpzLXF1b3RlIHsKICBjb2xvcjogI2EwYTFhNzsKICBmb250LXN0eWxlOiBpdGFsaWM7Cn0KCi5obGpzLWRvY3RhZywKLmhsanMta2V5d29yZCwKLmhsanMtZm9ybXVsYSB7CiAgY29sb3I6ICNhNjI2YTQ7Cn0KCi5obGpzLXNlY3Rpb24sCi5obGpzLW5hbWUsCi5obGpzLXNlbGVjdG9yLXRhZywKLmhsanMtZGVsZXRpb24sCi5obGpzLXN1YnN0IHsKICBjb2xvcjogI2U0NTY0OTsKfQoKLmhsanMtbGl0ZXJhbCB7CiAgY29sb3I6ICMwMTg0YmI7Cn0KCi5obGpzLXN0cmluZywKLmhsanMtcmVnZXhwLAouaGxqcy1hZGRpdGlvbiwKLmhsanMtYXR0cmlidXRlLAouaGxqcy1tZXRhIC5obGpzLXN0cmluZyB7CiAgY29sb3I6ICM1MGExNGY7Cn0KCi5obGpzLWF0dHIsCi5obGpzLXZhcmlhYmxlLAouaGxqcy10ZW1wbGF0ZS12YXJpYWJsZSwKLmhsanMtdHlwZSwKLmhsanMtc2VsZWN0b3ItY2xhc3MsCi5obGpzLXNlbGVjdG9yLWF0dHIsCi5obGpzLXNlbGVjdG9yLXBzZXVkbywKLmhsanMtbnVtYmVyIHsKICBjb2xvcjogIzk4NjgwMTsKfQoKLmhsanMtc3ltYm9sLAouaGxqcy1idWxsZXQsCi5obGpzLWxpbmssCi5obGpzLW1ldGEsCi5obGpzLXNlbGVjdG9yLWlkLAouaGxqcy10aXRsZSB7CiAgY29sb3I6ICM0MDc4ZjI7Cn0KCi5obGpzLWJ1aWx0X2luLAouaGxqcy10aXRsZS5jbGFzc18sCi5obGpzLWNsYXNzIC5obGpzLXRpdGxlIHsKICBjb2xvcjogI2MxODQwMTsKfQoKLmhsanMtZW1waGFzaXMgewogIGZvbnQtc3R5bGU6IGl0YWxpYzsKfQoKLmhsanMtc3Ryb25nIHsKICBmb250LXdlaWdodDogYm9sZDsKfQoKLmhsanMtbGluayB7CiAgdGV4dC1kZWNvcmF0aW9uOiB1bmRlcmxpbmU7Cn0K`;
|
@ -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
@ -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
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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;
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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.
@ -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'),
|
||||
|
@ -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;
|
||||
|
@ -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 => {
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
@ -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');
|
||||
});
|
||||
}
|
@ -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');
|
||||
}
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
29
packages/server/src/models/BackupItemModel.ts
Normal file
29
packages/server/src/models/BackupItemModel.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
31
packages/server/src/models/utils/email.ts
Normal file
31
packages/server/src/models/utils/email.ts
Normal 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];
|
||||
};
|
136
packages/server/src/routes/admin/emails.ts
Normal file
136
packages/server/src/routes/admin/emails.ts
Normal 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;
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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() {
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(),
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
15
packages/server/src/views/admin/email.mustache
Normal file
15
packages/server/src/views/admin/email.mustache
Normal file
@ -0,0 +1,15 @@
|
||||
<div class="block">
|
||||
<strong>Subject: </strong> {{email.subject}}<br/>
|
||||
<strong>From: </strong> {{sender.name}} <{{sender.email}}> (Sender ID: {{email.sender_id}})<br/>
|
||||
<strong>To: </strong> {{email.recipient_name}} <{{email.recipient_email}}>{{#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>
|
7
packages/server/src/views/admin/emails.mustache
Normal file
7
packages/server/src/views/admin/emails.mustache
Normal file
@ -0,0 +1,7 @@
|
||||
<form method='POST' action="{{postUrl}}">
|
||||
{{{csrfTag}}}
|
||||
|
||||
{{#emailTable}}
|
||||
{{>table}}
|
||||
{{/emailTable}}
|
||||
</form>
|
@ -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
|
||||
|
||||
|
@ -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}}
|
||||
|
@ -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> {{/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}}
|
@ -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
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user