1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-23 23:33:01 +02:00

Compare commits

..

54 Commits

Author SHA1 Message Date
Laurent Cozic
0d3bcdcb0e Merge branch 'dev' into server_admin_pages 2022-01-14 10:05:00 +00:00
Laurent
3fcdeb08d9 Server: Simplify Docker image (#6010)
- Removed complicated optimisation steps that didn't seem to optimise anything
- Delete Yarn cache after installation

After this, it should be back to the previous pre-Yarn size.
2022-01-13 17:39:58 +00:00
Laurent Cozic
41b6011769 Merge branch 'dev' into server_admin_pages 2022-01-13 14:53:09 +00:00
Laurent Cozic
d71eaa1a02 menu 2022-01-13 14:15:48 +00:00
Laurent Cozic
6e0ed87190 update 2022-01-13 13:51:02 +00:00
Roman Musin
ebaddfa4a8 Doc: GSoC 22 - email plugin idea (#6009) 2022-01-13 10:47:19 +00:00
Hieu-Thi Luong
2ab049305e Doc: Use correct flag for Vietnam (#5991)
Use correct Vietnam flag when build translation
2022-01-12 12:21:25 -05:00
Laurent Cozic
1ddf01f8b8 update 2022-01-11 18:25:03 +00:00
Laurent Cozic
bc4dbe1300 move more stuff under /admin 2022-01-11 17:55:09 +00:00
Laurent Cozic
c6900354f2 setup 2022-01-11 17:42:09 +00:00
Laurent Cozic
98bfb65ead Server: Improved string localization in views 2022-01-11 15:11:37 +00:00
Laurent Cozic
09cbe3cb7c Server: Put admin pages under /admin 2022-01-11 15:09:53 +00:00
Laurent Cozic
fd322edaab Server: Improved env variable validation and support true-false as boolean values 2022-01-11 15:02:53 +00:00
Laurent
09ed92bc26 Tools: Run stale action once a day at 16:00 2022-01-10 15:20:50 +00:00
Laurent Cozic
fb6069de6d Doc: Add Twitter link in header and fixed donate links 2022-01-10 12:56:25 +00:00
Laurent
39056ae1aa Added idea: 11. Improve plugin search and discoverability 2022-01-09 16:15:38 +00:00
Laurent Cozic
d031a04a2c Desktop: Adjusted styling to make it more consistent across app
There was two types of styling - the new one done using SCSS, which had
a 16px font, and the old one done js-in-css, which had a 13px font. Both
now have a 13px font. Also fixed margins on certain config screens.
2022-01-09 15:42:27 +00:00
Laurent Cozic
ed0f0fae01 Server: Set NODE_ENV to "production" in Docker image 2022-01-09 15:36:41 +00:00
Laurent Cozic
c185f00006 Server: Call server start command directly, without going through npm 2022-01-09 15:36:21 +00:00
Laurent Cozic
3117133be2 Desktop: Focus notebook title when opening Notebook dialog 2022-01-09 15:30:21 +00:00
Laurent Cozic
b92c650f5d Server: Fixes #5958: Fixed sharing notebook with a user that does not have E2EE enabled 2022-01-09 15:20:23 +00:00
Laurent Cozic
9dbf5e02eb Server: Remove uneeded CSS file 2022-01-09 12:04:54 +00:00
reportxx
e1016b8af4 Update Swedish translation (#5985) 2022-01-09 11:35:40 +00:00
Thibault Jan Beyer
3ba4a1de72 Doc: Update editorCommandDeclarations link (#5976) 2022-01-09 11:32:53 +00:00
Kenichi Kobayashi
a683f12622 Desktop: Clickable tags in Tag Bar (#5956) 2022-01-09 11:32:21 +00:00
Nicholas Hobbs
89184a3f9f Doc: Add libvips dependency for Apple Silicon build instructions (#5966) 2022-01-09 11:28:13 +00:00
Kenichi Kobayashi
24dbede6c1 Desktop: Fixes #5890: Scroll jump when checkbox is toggled in Viewer (#5941) 2022-01-09 11:26:40 +00:00
Kenichi Kobayashi
70e623e741 Desktop: Fixes #5918: Scroll jumps when images are rendered in Markdown Editor (#5929) 2022-01-09 11:26:03 +00:00
Jonathan Heard
5c77317735 All: Show login prompt for OneDrive (#5933) 2022-01-09 11:25:24 +00:00
Shing Lyu
9684b38f7e Desktop: Fixes #5875: Show error on sync if S3 region is not set (#5923) 2022-01-09 11:24:24 +00:00
Helmut K. C. Tessarek
3dfe43204d Desktop: Fix wording "Check for updates" in settings (#5832) 2022-01-09 11:23:24 +00:00
Caleb John
16148b2255 Desktop: Fixes #5808: Scrolling was out of sync when a Multi Markdown Table was being used (#5815) 2022-01-09 11:22:22 +00:00
Laurent Cozic
df14ffdee2 Server: Resolves #5222: Fixed handling of mailer security settings, and changed env variable name MAILER_SECURE => MAILER_SECURITY, and default port 587 => 465 (Breaking change) 2022-01-09 11:14:16 +00:00
Laurent
f6ed5eb064 Update close-stale-issues.yml 2022-01-08 21:51:28 +00:00
Zander Hill
a14115bfd7 Generator: When publishing a plugin, only include files in the "publish" directory (#5971) 2022-01-08 19:18:49 +00:00
Krishna Kumar
dd68d6cf2c Desktop: Fixes #5916: Fixed search icon when note list is resized (#5974) 2022-01-08 19:12:18 +00:00
Laurent Cozic
7d3555d62c Doc: Fix email address 2022-01-08 17:19:21 +00:00
Laurent Cozic
af472528a2 Doc: Add CLA document for Joplin Server 2022-01-08 17:18:30 +00:00
Ilia Kondrashov
5c8b0ec1cb Doc: Remove unsupported AWS IAM policy statement (#5982) 2022-01-08 17:02:45 +00:00
Laurent
5e20e890d8 Tools: Use GitHub Actions to close stale issues (#5979)
Stale bot appears to be broken and discontinued.
2022-01-08 12:50:37 +00:00
Laurent Cozic
50dc656f65 Doc: Move faq entry to correct document 2022-01-07 18:12:47 +00:00
Laurent Cozic
b36cf46a06 Plugin Repo: Improved error message when plugin "publish" directory is missing 2022-01-07 18:12:47 +00:00
Roman Musin
1781334374 Doc: A few more GSoC ideas (#5959) 2022-01-06 16:42:13 -05:00
Laurent Cozic
71140a638f Minor clipper tweaks 2022-01-06 15:46:28 +00:00
Laurent Cozic
6ba0fce237 Doc: Update Joplin Cloud FAQ 2022-01-06 12:22:23 +00:00
Laurent Cozic
c033a343c1 Doc: Update Joplin Server license 2022-01-06 12:00:54 +00:00
Daeraxa
898c96a566 Tools: Fix desktop app build (#5960) 2022-01-06 10:54:10 +00:00
CandleCandle
b83fa133b2 Doc: Default S3 permissions require 'GetObject' (#5957) 2022-01-05 16:46:41 +00:00
reportxx
ec7fec8b59 All: Translation: Update sv.po (#5950) 2022-01-03 18:14:37 -05:00
Fusion future
b2fb4f2ea2 Desktop: Add "X-GNOME-SingleWindow=true" to the desktop file (#5948)
Since this commit (ebd2acd98e),
Plasma Desktop will check "X-GNOME-SingleWindow" property to determine
whether to show "Open New Window" action in the context menu of a task.

Joplin cannot launch a new instance or open a new window, so add the
property and set it to true to hide the action.
2022-01-02 21:15:08 -07:00
Laurent Cozic
c74e51a58e Tools: Apply "withRetry" technique to copyPluginAssets too to try to fix CI 2022-01-02 18:38:06 +01:00
Daeraxa
7cba4be498 Doc: update e2ee spec paths and links (#5938) 2022-01-02 12:03:07 -05:00
giorgi shengelaia
19b396f2ec Doc: fix grammar and typos (#5946) 2022-01-02 11:57:30 -05:00
Laurent
4ed7c340a0 Tools: Fixed Docker image building and make it more stable (#5942) 2022-01-02 12:48:03 +00:00
90 changed files with 2112 additions and 734 deletions

View File

@@ -1,12 +1,19 @@
_mydocs/
_releases/
.git/
.yarn/cache/
**/.DS_Store
**/node_modules
Assets/
.git/
_releases/
packages/app-desktop
packages/app-cli
packages/app-mobile
packages/app-clipper
packages/generator-joplin
packages/plugin-repo-cli
docs/
lerna-debug.log
packages/app-cli/
packages/app-clipper/
packages/app-desktop/
packages/app-mobile/
packages/generator-joplin/
packages/plugin-repo-cli/
packages/server/db-*.sqlite
packages/server/temp
packages/server/dist/
packages/server/logs/
packages/server/temp/

25
.github/stale.yml vendored
View File

@@ -1,25 +0,0 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- "good first issue"
- "upstream"
- "backlog"
- "high"
- "medium"
- "spec"
- "cannot reproduce"
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs.
You may comment on the issue and I will leave it open.
Thank you for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please feel free to create a new issue with up-to-date information.
only: issues

View File

@@ -0,0 +1,23 @@
name: 'Close stale issues'
on:
schedule:
- cron: '0 16 * * *'
permissions:
issues: write
jobs:
ProcessStaleIssues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v4
with:
# Use this to do a dry run from a pull request
# debug-only: true
stale-issue-message: "Hey there, it looks like there has been no activity on this issue recently. Has the issue been fixed, or does it still require the community's attention? This issue may be closed if no further activity occurs. You may comment on the issue and I will leave it open. Thank you for your contributions."
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'
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
days-before-pr-stale: -1

View File

@@ -0,0 +1,43 @@
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

@@ -728,6 +728,23 @@ footer .bottom-links-row p {
}
}
/*****************************************************************
MEDIUM VIEW
- Make menu bar elements smaller and closer to each others
so that everything fit.
*****************************************************************/
@media (max-width: 990px) {
#nav-section > .container {
max-width: 960px;
}
#nav-section .button-link {
padding: 4px 12px;
font-size: 15px;
}
}
/*****************************************************************
NARROW VIEW
- Top right menu is displayed
@@ -740,6 +757,23 @@ footer .bottom-links-row p {
padding-bottom: 130px;
}
#menu-mobile .social-links {
display: flex;
justify-content: center;
margin-top: 20px;
}
#menu-mobile .social-links a {
margin-left: 15px;
font-size: 20px;
}
#menu-mobile .social-links .social-link-mastodon,
#menu-mobile .social-links .social-link-reddit,
#menu-mobile .social-links .social-link-patreon {
display: none;
}
.front-page h1 {
font-size: 2.5em;
}
@@ -857,7 +891,7 @@ footer .bottom-links-row p {
}
#menu-mobile .button-link {
padding: 10px 15px;
padding: 4px 12px;
font-size: 16px;
margin-left: 0px;
}

View File

@@ -1,15 +1,6 @@
<footer class="darkblue-bg">
<div class="container">
<div class="row">
<div class="col-12 col-md-12 social-links">
<a href="https://twitter.com/joplinapp" title="Joplin Twitter feed"><i class="fab fa-twitter"></i></a>
<a href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
<a href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
<a href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
<a href="https://www.reddit.com/r/joplinapp/" title="Joplin Subreddit"><i class="fab fa-reddit"></i></a>
<a href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
</div>
</div>
{{> socialFeeds}}
<div class="row bottom-links-row">
<div class="col-12 col-md-6">

View File

@@ -12,7 +12,8 @@
</a>
</div>
<div class="col-9 text-right d-none d-md-block">
<a href="{{baseUrl}}/news/" class="fw500">What's New</a>
<a href="https://twitter.com/joplinapp" title="Joplin Twitter feed" class="fw500"><i class="fab fa-twitter"></i></a>
<a href="{{baseUrl}}/news/" class="fw500">News</a>
<a href="{{baseUrl}}/help/" class="fw500">Help</a>
<a href="{{forumUrl}}" class="fw500">Forum</a>
{{#showJoplinCloudLinks}}
@@ -43,7 +44,7 @@
</div>
<div class="text-center menu-mobile-top">
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">What's New</a>
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
</div>
@@ -59,6 +60,8 @@
{{#showToc}}
<div id="toc-mobile">{{{tocHtml}}}</div>
{{/showToc}}
{{> socialFeeds}}
<div>
<p class="light-blue mobile-menu-link-bottom text-center">

View File

@@ -0,0 +1,10 @@
<div class="row">
<div class="col-12 col-md-12 social-links">
<a class="social-link-twitter" href="https://twitter.com/joplinapp" title="Joplin Twitter feed"><i class="fab fa-twitter"></i></a>
<a class="social-link-mastodon" href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
<a class="social-link-patreon" href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
<a class="social-link-discord" href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
<a class="social-link-reddit" href="https://www.reddit.com/r/joplinapp/" title="Joplin Subreddit"><i class="fab fa-reddit"></i></a>
<a class="social-link-github" href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
</div>
</div>

View File

@@ -20,7 +20,7 @@ There are also a few forks of existing packages under the "fork-*" name.
- Install node 16+ - https://nodejs.org/en/
- [Enable yarn](https://yarnpkg.com/getting-started/install): `corepack enable`
- macOS: Install Cocoapods - `brew install cocoapods`
- 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`
@@ -60,7 +60,7 @@ Normally the **bundler** should start automatically with the application. If it
## Building the clipper
cd packages/app-clipper/popup
yarn run watch # To watch for changes
npm run watch # To watch for changes
To test the extension please refer to the relevant pages for each browser: [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Your_first_WebExtension#Trying_it_out) / [Chrome](https://developer.chrome.com/docs/extensions/mv3/getstarted/). Please note that the extension in dev mode will only connect to a dev instance of the desktop app (and vice-versa).

View File

@@ -31,12 +31,12 @@ Joplin is available in multiple languages thanks to the help of its users. You c
If you want to start contributing to the project's code, please follow these guidelines before creating a pull request:
- Explain WHY you want to add this change. Explain it inside the pull request and you may link to an issue for additional information, but the PR should gives a clear overview of why you want to add this.
- Explain WHY you want to add this change. Explain it inside the pull request and you may link to an issue for additional information, but the PR should give a clear overview of why you want to add this.
- Bug fixes are always welcome. Start by reviewing the [list of bugs](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
- A good way to easily start contributing is to pick and work on a [good first issue](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). We try to make these issues as clear as possible and provide basic info on how the code should be changed, and if something is unclear feel free to ask for more information on the issue.
- Before adding a new feature, ask about it in the [Github Issue Tracker](https://github.com/laurent22/joplin/issues?utf8=%E2%9C%93&q=is%3Aissue) or the [Joplin Forum](https://discourse.joplinapp.org/), or check if existing discussions exist to make sure the new functionality is desired.
- **Changes that will consist in more than 50 lines of code should be discussed the [Joplin Forum](https://discourse.joplinapp.org/)**, so that you don't spend too much time implementing something that might not be accepted.
- All the applications share the same backend (database, synchronisation, settings, models, business logic, etc.) so if you change something in the backend in one app, makes sure it still work in the other apps. Usually it does, but keep this in mind.
- **Changes that will consist of more than 50 lines of code should be discussed on the [Joplin Forum](https://discourse.joplinapp.org/)**, so that you don't spend too much time implementing something that might not be accepted.
- All the applications share the same backend (database, synchronisation, settings, models, business logic, etc.) so if you change something in the backend in one app, make sure it still works in the other apps. Usually it does, but keep this in mind.
- Pull requests that make many changes using an automated tool, like for spell fixing, styling, etc. will not be accepted. An exception would be if the changes have been discussed in the forum and someone has agreed to review **and test** the pull request.
- Pull requests that make address multiple issues will most likely stall and eventually be closed. This is because we might be fine with one of the changes but not with others and untangling that kind of pull request is too much hassle both for maintainers and the person who submitted it. So most of the time someone gives up and the PR gets closed. So please keep the pull request focused on one issue.

View File

@@ -8,66 +8,36 @@ RUN apt-get update \
# Enables Yarn
RUN corepack enable
RUN echo "Node: $(node --version)"
RUN echo "Npm: $(npm --version)"
RUN echo "Yarn: $(yarn --version)"
RUN echo "Node: $(node --version)" \
&& echo "Npm: $(npm --version)" \
&& echo "Yarn: $(yarn --version)"
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user
USER $user
ENV NODE_ENV development
ENV NODE_ENV production
ENV RUNNING_IN_DOCKER 1
EXPOSE ${APP_PORT}
WORKDIR /home/$user
RUN mkdir /home/$user/logs
RUN mkdir /home/$user/logs \
&& mkdir /home/$user/.yarn
# Install the root scripts but don't run postinstall (which would bootstrap
# and build TypeScript files, but we don't have the TypeScript files at
# this point)
COPY --chown=$user:$user package*.json ./
COPY --chown=$user:$user .yarn ./.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 .
RUN yarn install --inline-builds --mode=skip-build
# To take advantage of the Docker cache, we first copy all the package.json
# and package-lock.json files, as they rarely change, and then bootstrap
# all the packages.
#
# Note that bootstrapping the packages will run all the postinstall
# scripts, which means that for packages that have such scripts, we need to
# copy all the files.
#
# We can't run boostrap with "--ignore-scripts" because that would
# prevent certain sub-packages, such as sqlite3, from being built
COPY --chown=$user:$user packages/fork-sax/package*.json ./packages/fork-sax/
COPY --chown=$user:$user packages/fork-uslug/package*.json ./packages/fork-uslug/
COPY --chown=$user:$user packages/htmlpack/package*.json ./packages/htmlpack/
COPY --chown=$user:$user packages/renderer/package*.json ./packages/renderer/
COPY --chown=$user:$user packages/tools/package*.json ./packages/tools/
COPY --chown=$user:$user packages/lib/package*.json ./packages/lib/
COPY --chown=$user:$user tsconfig.json .
# The following have postinstall scripts so we need to copy all the files.
# Since they should rarely change this is not an issue
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/
# Then bootstrap only, without compiling the TypeScript files
RUN yarn install --inline-builds --mode=skip-build
# Now copy the source files. Put lib and server last as they are more likely to change.
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
@@ -76,21 +46,23 @@ COPY --chown=$user:$user packages/tools ./packages/tools
COPY --chown=$user:$user packages/lib ./packages/lib
COPY --chown=$user:$user packages/server ./packages/server
# Finally build everything, in particular the TypeScript files. We can't just
# run `yarn run build` because that wouldn't run the postinstall scripts in
# dependencies (for example the sqlite3 native module would not be built). So
# instead we run `yarn install`, which is going to install again all the
# packages (but because it's already done it should be fast), and then run the
# postinstall scripts, as well as build scripts.
# 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
# but we can delete it anyway. We can delete the cache because we use
# `nodeLinker: node-modules`. If we ever implement Zero Install, we'll need to
# keep the cache.
#
# Note that `yarn install` ignores `NODE_ENV=production` and will install dev
# dependencies too, but this is fine because we need them to build the app.
RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds
RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
&& yarn cache clean \
&& rm -rf .yarn/berry
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
# Not clear what's the equivalent of "--prefix" in Yarn 3, so keep using npm for
# now.
CMD [ "npm", "--prefix", "packages/server", "start" ]
# 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" ]
# Build-time metadata
# https://github.com/opencontainers/image-spec/blob/master/annotations.md

View File

@@ -219,6 +219,7 @@ then
Type=Application
Categories=Office;
MimeType=x-scheme-handler/joplin;
X-GNOME-SingleWindow=true
EOF
# Update application icons

10
LICENSE
View File

@@ -1,9 +1,9 @@
All code in this repository is licensed under the MIT License **unless a
directory contains a LICENSE file**, in which case that LICENSE file applies to
the code in that sub-directory.
directory contains a LICENSE or LICENSE.md file**, in which case that file
applies to the code in that sub-directory.
For example, packages/fork-sax contains a ISC LICENSE file, thus all code under
the packages/fork-sax directory is licensed under ISC.
For example, packages/server contains a LICENSE.md file, thus all code under the
packages/server directory is licensed under that license.
For example, packages/app-cli does NOT contain a LICENSE file, thus all code
under that directory is licensed under the default license, which is MIT.
@@ -20,7 +20,7 @@ icons please contact the author in order to get a permission.
MIT License
Copyright (c) 2016-2021 Laurent Cozic
Copyright (c) 2016-2022 Laurent Cozic
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in

View File

@@ -300,10 +300,10 @@ To add a **Bucket Policy** from the AWS S3 Web Console, navigate to the **Permis
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:PutObject"

View File

@@ -78,5 +78,8 @@
"node-gyp": "^8.4.1",
"nodemon": "^2.0.9"
},
"packageManager": "yarn@3.1.1"
"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"
}
}

View File

@@ -19,7 +19,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Joplin Web Clipper</title>
</head>
<body>
<noscript>

View File

@@ -62,7 +62,7 @@ import ShareService from '@joplin/lib/services/share/ShareService';
import checkForUpdates from './checkForUpdates';
import { AppState } from './app.reducer';
import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog';
import eventManager from '../lib/eventManager';
import eventManager from '@joplin/lib/eventManager';
// import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
const pluginClasses = [
@@ -446,8 +446,9 @@ class Application extends BaseApplication {
await this.checkForLegacyTemplates();
// Note: Auto-update currently doesn't work in Linux: it downloads the update
// but then doesn't install it on exit.
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {

View File

@@ -56,7 +56,7 @@ class ClipperConfigScreenComponent extends React.Component {
const containerStyle = Object.assign({}, theme.containerStyle, {
overflowY: 'scroll',
padding: theme.configScreenPadding,
// padding: theme.configScreenPadding,
backgroundColor: theme.backgroundColor3,
});

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useCallback, useState, useRef, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import Dialog from '../Dialog';
@@ -22,6 +22,7 @@ interface Props {
export default function(props: Props) {
const [folderTitle, setFolderTitle] = useState('');
const [folderIcon, setFolderIcon] = useState<FolderIcon>();
const titleInputRef = useRef(null);
const isNew = !props.folderId;
@@ -41,6 +42,14 @@ export default function(props: Props) {
});
}, [props.dispatch]);
useEffect(() => {
titleInputRef.current.focus();
setTimeout(() => {
titleInputRef.current.select();
}, 100);
}, []);
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
if (event.buttonName === 'cancel') {
onClose();
@@ -90,7 +99,7 @@ export default function(props: Props) {
<div className="form">
<div className="form-input-group">
<label>{_('Title')}</label>
<StyledInput type="text" value={folderTitle} onChange={onFolderTitleChange}/>
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
</div>
<div className="form-input-group">

View File

@@ -5,7 +5,7 @@ export default function styles(themeId: number) {
return {
container: {
...theme.containerStyle,
padding: theme.configScreenPadding,
// padding: theme.configScreenPadding,
backgroundColor: theme.backgroundColor3,
},
actionsContainer: {

View File

@@ -67,7 +67,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
usePluginServiceRegistration(ref);
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, editor_resize,
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, editor_resize, getLineScrollPercent,
} = useScrollHandler(editorRef, webviewRef, props.onScroll);
const codeMirror_change = useCallback((newBody: string) => {
@@ -576,9 +576,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, props.content);
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
if (editorRef.current) {
editorRef.current.updateBody(newBody);
// To cancel CodeMirror's layout drift, the scroll position
// is recorded before updated, and then it is restored.
// Ref. https://github.com/laurent22/joplin/issues/5890
const percent = getLineScrollPercent();
editorRef.current.replaceRange(line, from, to);
setEditorPercentScroll(percent);
}
} else if (msg === 'percentScroll') {
const percent = arg0;

View File

@@ -7,6 +7,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
const ignoreNextEditorScrollTime_ = useRef(Date.now());
const ignoreNextEditorScrollEventCount_ = useRef(0);
const delayedSetEditorPercentScrollTimeoutID_ = useRef(null);
const lastResizeHeight_ = useRef(NaN);
// Ignores one next scroll event for a short time.
const ignoreNextEditorScrollEvent = () => {
@@ -90,8 +91,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
}, [scheduleOnScroll]);
const editor_scroll = useCallback(() => {
if (isNextEditorScrollEventIgnored()) return;
const ignored = isNextEditorScrollEventIgnored();
const cm = editorRef.current;
if (isCodeMirrorReady(cm)) {
const editorPercent = Math.max(0, Math.min(1, cm.getScrollPercent()));
@@ -104,7 +104,9 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
// calculates GUI-independent line-based percent
const percent = translateScrollPercentE2L(cm, editorPercent);
scrollPercent_.current = percent;
setViewerPercentScroll(percent);
if (!ignored) {
setViewerPercentScroll(percent);
}
}
}
}, [setViewerPercentScroll]);
@@ -117,13 +119,29 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
}, []);
const editor_resize = useCallback((cm) => {
if (cm) {
restoreEditorPercentScroll();
if (isCodeMirrorReady(cm)) {
// Only when resized, the scroll position is restored.
const info = cm.getScrollInfo();
const height = info.height - info.clientHeight;
if (height !== lastResizeHeight_.current) {
restoreEditorPercentScroll();
lastResizeHeight_.current = height;
}
}
}, []);
const getLineScrollPercent = useCallback(() => {
const cm = editorRef.current;
if (isCodeMirrorReady(cm)) {
const ePercent = cm.getScrollPercent();
return translateScrollPercentE2L(cm, ePercent);
} else {
return scrollPercent_.current;
}
}, []);
return {
resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll, editor_resize,
resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll, editor_resize, getLineScrollPercent,
};
}

View File

@@ -15,6 +15,7 @@ export const Root = styled.div`
position: relative;
display: flex;
width: 100%;
min-width: 30px;
`;
interface Props {

View File

@@ -1,14 +1,15 @@
const React = require('react');
const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme');
const CommandService = require('@joplin/lib/services/CommandService').default;
class TagItemComponent extends React.Component {
render() {
const theme = themeStyle(this.props.themeId);
const style = Object.assign({}, theme.tagStyle);
const title = this.props.title;
const { title, id } = this.props;
return <span style={style}>{title}</span>;
return <button style={style} onClick={() => CommandService.instance().execute('openTag', id)}>{title}</button>;
}
}

View File

@@ -42,6 +42,7 @@ function TagList(props: Props) {
for (let i = 0; i < tags.length; i++) {
const props = {
title: tags[i].title,
id: tags[i].id,
key: tags[i].id,
};
output.push(<TagItem {...props} />);

View File

@@ -151,7 +151,7 @@ General classes
body, button {
color: var(--joplin-color);
font-size: 16px;
font-size: 13px;
}
div, span, a {
@@ -159,7 +159,7 @@ div, span, a {
}
h2 {
font-size: 24px;
font-size: 20px;
&.-no-top-margin {
margin-top: 0;
@@ -193,7 +193,7 @@ div.form,
p {
&.-small {
font-size: 13px;
font-size: 11px;
}
}

View File

@@ -10,6 +10,12 @@
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1
# ----------------------------------------------------------------------------------
# First user has E2EE, but second one doesn't:
# ----------------------------------------------------------------------------------
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1
# ----------------------------------------------------------------------------------
# Without E2EE:
# ----------------------------------------------------------------------------------

View File

@@ -1,5 +1,29 @@
const { copy, mkdirp, remove } = require('fs-extra');
const msleep = async (ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
};
// Same as copyApplicationAssets - probably both scripts should be merged in
// one.
const withRetry = async (fn) => {
for (let i = 0; i < 5; i++) {
try {
await fn();
return;
} catch (error) {
console.warn(`withRetry: Failed calling function - will retry (${i})`, error);
await msleep(1000 + i * 1000);
}
}
throw new Error('withRetry: Could not run function after multiple attempts');
};
async function main() {
const rootDir = `${__dirname}/..`;
@@ -12,11 +36,11 @@ async function main() {
for (const action of ['delete', 'copy']) {
for (const destDir of destDirs) {
if (action === 'delete') {
await remove(destDir);
await withRetry(() => remove(destDir));
} else {
console.info(`Copying to ${destDir}`);
await mkdirp(destDir);
await copy(sourceDir, destDir, { overwrite: true });
await withRetry(() => mkdirp(destDir));
await withRetry(() => copy(sourceDir, destDir, { overwrite: true }));
}
}
}

View File

@@ -10,6 +10,9 @@
"keywords": [
"joplin-plugin"
],
"files": [
"publish"
],
"devDependencies": {
"@types/node": "^14.0.14",
"chalk": "^4.1.0",

View File

@@ -98,14 +98,14 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
// We could implement that here, but the above workaround saves some code.
static async checkConfig(options) {
const fileApi = await SyncTargetAmazonS3.newFileApi_(SyncTargetAmazonS3.id(), options);
fileApi.requestRepeatCount_ = 0;
const output = {
ok: false,
errorMessage: '',
};
try {
const fileApi = await SyncTargetAmazonS3.newFileApi_(SyncTargetAmazonS3.id(), options);
fileApi.requestRepeatCount_ = 0;
const headBucketReq = new Promise((resolve, reject) => {
fileApi.driver().api().send(

View File

@@ -35,7 +35,7 @@ export const runtime = (): CommandRuntime => {
return 'auth';
}
reg.logger().info('Not authentified with sync target - please check your credential.');
reg.logger().error('Not authenticated with sync target - please check your credentials.');
return 'error';
}
@@ -43,8 +43,13 @@ export const runtime = (): CommandRuntime => {
try {
sync = await reg.syncTarget().synchronizer();
} catch (error) {
reg.logger().info('Could not acquire synchroniser:');
reg.logger().info(error);
reg.logger().error('Could not initialise synchroniser: ');
reg.logger().error(error);
error.message = `Could not initialise synchroniser: ${error.message}`;
utils.store.dispatch({
type: 'SYNC_REPORT_UPDATE',
report: { errors: [error] },
});
return 'error';
}

View File

@@ -248,7 +248,7 @@ shared.toggleIsTodo_onPress = function(comp) {
comp.setState(newState);
};
shared.toggleCheckbox = function(ipcMessage, noteBody) {
function toggleCheckboxLine(ipcMessage, noteBody) {
const newBody = noteBody.split('\n');
const p = ipcMessage.split(':');
const lineIndex = Number(p[p.length - 1]);
@@ -281,7 +281,18 @@ shared.toggleCheckbox = function(ipcMessage, noteBody) {
} else {
line = line.replace(/- \[x\] /i, '- [ ] ');
}
return [newBody, lineIndex, line];
}
shared.toggleCheckboxRange = function(ipcMessage, noteBody) {
const [lineIndex, line] = toggleCheckboxLine(ipcMessage, noteBody).slice(1);
const from = { line: lineIndex, ch: 0 };
const to = { line: lineIndex, ch: line.length };
return { line, from, to };
};
shared.toggleCheckbox = function(ipcMessage, noteBody) {
const [newBody, lineIndex, line] = toggleCheckboxLine(ipcMessage, noteBody);
newBody[lineIndex] = line;
return newBody.join('\n');
};

View File

@@ -105,7 +105,7 @@ shared.synchronize_press = async function(comp) {
return 'auth';
}
reg.logger().info('Not authentified with sync target - please check your credential.');
reg.logger().error('Not authenticated with sync target - please check your credentials.');
return 'error';
}
@@ -113,8 +113,13 @@ shared.synchronize_press = async function(comp) {
try {
sync = await reg.syncTarget().synchronizer();
} catch (error) {
reg.logger().info('Could not acquire synchroniser:');
reg.logger().info(error);
reg.logger().error('Could not initialise synchroniser: ');
reg.logger().error(error);
error.message = `Could not initialise synchroniser: ${error.message}`;
comp.props.dispatch({
type: 'SYNC_REPORT_UPDATE',
report: { errors: [error] },
});
return 'error';
}

View File

@@ -1190,7 +1190,7 @@ class Setting extends BaseModel {
},
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically update the application') },
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
'sync.interval': {

View File

@@ -89,6 +89,7 @@ export default class OneDriveApi {
scope: 'files.readwrite offline_access sites.readwrite.all',
response_type: 'code',
redirect_uri: redirectUri,
prompt: 'login',
};
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${stringify(query)}`;
}

View File

@@ -17,7 +17,7 @@ import { Command } from './types';
*
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands)
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands)
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.ts)
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts)
*
* To view what arguments are supported, you can open any of these files
* and look at the `execute()` command.

View File

@@ -227,6 +227,7 @@ function addExtraStyles(style: any) {
justifyContent: 'center',
marginRight: 8,
borderRadius: 100,
borderWidth: 0,
};
style.toolbarStyle = {

View File

@@ -51,6 +51,8 @@ async function extractPluginFilesFromPackage(existingManifests: any, workDir: st
const pluginDir = resolveRelativePathWithinDir(workDir, 'node_modules', packageName, 'publish');
if (!(await fs.pathExists(pluginDir))) throw new Error(`Could not find publish directory at ${pluginDir}`);
const files = await fs.readdir(pluginDir);
const manifestFilePath = path.resolve(pluginDir, files.find((f: any) => path.extname(f) === '.json'));
const pluginFilePath = path.resolve(pluginDir, files.find((f: any) => path.extname(f) === '.jpl'));

View File

@@ -1,7 +0,0 @@
Copyright (c) 2017-2021 Laurent Cozic
Personal Use License
Joplin Server is available for personal use only. For example you may host the software on your own server for non-commercial activity.
To obtain a license for commercial purposes, please contact us.

282
packages/server/LICENSE.md Normal file
View File

@@ -0,0 +1,282 @@
**JOPLIN SERVER PERSONAL USE LICENSE**
v.1: 21 December 2021
This Joplin Server Personal Use License (the "**License**") is a legally binding
agreement between Cozic Ltd. registered under the laws of England and Wales
(the "**Licensor**"), the owner of the server entitled ‘Joplin Server,’ the
related software applications, and services (collectively, the "**Software**")
and an individual user accessing the Software (the "**Licensee**") (each
individually a "**Party**" and collectively, the "**Parties**").
# 1. Acceptance of the License
* **1.1** By accessing or otherwise using the Software, the Licensee confirms
that the Licensee has read the License, accepts the terms of the License, and
agrees to become legally bound by the License.
* **1.2** If the Licensee is entering into the License on behalf of a legal
entity, the Licensee represents that the Licensee has the authority and the
necessary capacity to bind such entity and its affiliates to the terms of the
License.
* **1.3** If the Licensee does not have the authority specified in section 1.2
or if the Licensee does not agree with one or more provisions of the License,
the Licensee is not allowed to access the Software and the Licensee must not
accept the License.
* **1.4** By using the Software, the Licensee acknowledges, agrees, and warrants
that the Licensee:
* i. Shall comply with the terms of the License and all applicable local,
state, national and foreign laws, treaties, and regulations;
* ii. Shall provide only true, accurate, complete, and up-to-date
information; and
* iii. Has the capacity to conclude legally binding contracts with the
Licensor.
# 2. Grant of RIGHTS
* **2.1** The Licensor hereby grants the Licensee a worldwide, non-exclusive,
royalty free, and revocable license to access and use the Software in
accordance with the terms of this License and the applicable laws.
* **2.2** Subject to the terms of the License, the Licensee is entitled to
access and use the Software for non-commercial purposes for taking personal
and professional notes, sharing notes with other users of the Software, and
synchronising data.
* **2.3** Unless authorised by the Licensor in writing, the Software may be used
for personal non-commercial purposes only. The Licensee is allowed to grant
access to the Software to others for non-commercial purposes, provided that
(i) the Licensee is not a business entity, (ii) the Licensee or the third
party to which the access is granted does not use the Software to generate
profits of any kind, and (iii) the Software is used for non-commercial
purposes only. The Licensee is not allowed to: (i) use the Software for
commercial purposes and (ii) grant others the right to use the Software for a
fee or for any commercial purposes, including, without limitation, copying,
reproducing, publishing, transmitting, transferring, selling, renting,
modifying, creating derivative works from, distributing, reposting,
performing, displaying, or in any other way commercially exploiting the
Software without prior written authorisation from the Licensor. By way of
illustration, the Licensee is not allowed to:
1. Install the Software on Licensee’s infrastructure and charge others for
the use of the Software;
2. Install the Software behind a proxy and charge others for the use of the
Software;
3. Install the Software on a Licensee’s or third party’s server and provide
access to the Software to third parties (a) for a fee or (b) free of
charge, if the said third parties may use the Software for commercial
purposes;
4. Grant access to the Software to others for a fee or for any commercial
purposes;
5. Grant access to the Software to others for a fee or free of charge if
the Licensee is a legal or natural person engaged in commercial
activities; or
6. Use the Software or grant other the right to use the Software in a way
that generates income or commercial profits.
* **2.4** This License does not govern any commercial use of the Software, as
authorised by the Licensor.
* **2.5** The Software is licensed and not sold. By accepting the License, the
Licensee obtains the right to use the Software and not the ownership of the
Software.
* **2.6** The Licensor reserves any rights not expressly granted to the
Licensee under this License.
* **2.7** The Licensee is allowed to make a reasonable number of copies of the
Software, as and if necessary for the purposes set forth herein, provided that
only complete copies of the Software are made, including without limitation
all ‘read me’ files, copyright notices, and other legal notices and terms
included in the Software.
* **2.8** The Licensee is permitted to load and run the Software on any device,
network or cloud virtual machines under Licensee’s control (collectively, the
"**Devices**"), if such Devices are compatible with the Software. The Licensee
is solely responsible for assessing the compatibility of the Devices to be
used with the Software.
* **2.9** It is Licensee’s sole responsibility to verify and assess the
suitability, validity and integrity of the Software prior to using it and to
decide whether or not the Software fits for the intended use.
* **2.10** The Licensor reserves the right to grant the right to use the
Software to third parties.
* **2.11** The Licensee acknowledges and agrees that any use of the Software
that is prohibited by this License may be unlawful and may result in
Licensee’s criminal liability.
# 3. Covered Software and services
* **3.1** The License applies only to the Software as provided to the Licensee
by the Licensor. The License also applies to updates, supplements, and support
services related to the Software, or any other services provided in relation
to the Software, unless other terms and conditions have been provided thereto.
* **3.2** Any software or services that are not provided by the Licensor are
not covered by this License. Such Software and services are subject to the
terms and conditions set by the respective third party and the Licensee is
solely responsible for obtaining, agreeing to, and complying with the
respective terms and conditions at its own cost and expense.
* **3.3** The Licensor reserves the right, but is not under any obligation, to
provide paid or free-of-charge updates and technical support services with
regard to the Software, including fixing bugs and errors, and the possibility
to use new versions of the Software.
# 4. Intellectual property and ownership
* **4.1** All title and copyright in and to the Software (including, but not
limited to, any source code, images, graphics, photographs, animations, video,
audio, music, text, and applets, incorporated in the Software) are owned by
the Licensor. The Software is protected by the English copyright laws and
international treaties. The Licensee is not allowed to incorporate any portion
of the Software into other programs or compile any portion of it in
combination with other programs, or otherwise copy (except to exercise rights
granted in this License), modify, create derivative works of, distribute,
assign any rights to, or license the Software in whole or in part.
* **4.2** The Licensee is not permitted to, without obtaining prior written
authorisation from the Licensor, to use the trade names, trademarks, service
marks or product names of the Licensor, except as required for the use of the
Software.
* **4.3** The Licensor has made all efforts possible to avoid the Software
being subject to the rights of third parties, in particular that its use does
not infringe patents, copyrights or other intellectual property rights of
third parties. However, the Licensor does not guarantee that the Software is
not subject to the rights of third parties. The Licensee agrees to notify the
Licensor immediately and in writing if any third party asserts an infringement
claim against the Licensee in connection with the Software.
* **4.4** By submitting any content through the Software (the "Licensee’s
Content"), the Licensee grants the Licensor unrestricted, sub-licensable,
royalty-free, perpetual, and irrevocable rights to process the Licensee’s
Content for the purposes of providing the Licensee with the Software and
carrying out Licensor’s legitimate business interests.
* **4.5** The Licensee is not permitted to remove any copyright or other
proprietary notices and legends.
# 5. PRIVACY AND DATA PROTECTION
* **5.1** The Licensor does not have access in any manner to the Licensee’s
Content. Therefore, the Licensee is solely responsible for creating, keeping
and maintaining any backup copies of any Licensee’s Content or other
information submitted to, through, or in relation to the Software.
The Parties agree to individually comply with the applicable data protection
laws pertaining to the Software.
# 6. AVAILABILITY
* **6.1** The availability of the Software may be affected by factors, which
the Licensor cannot reasonably control, such as bandwidth problems, equipment
failure, acts and omissions of our third-party service providers, or *force
majeure* events. The Licensor takes no responsibility for the unavailability
of the Software caused by such factors.
# 7. Limitation of liability and disclaimer of warranties
* **7.1** To the extent permitted by the applicable law, the Licensor expressly
disclaims all warranties, express or implied, for the Software. The Licensor
provides the Software on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
ANY KIND, either express or implied, including, without limitation, any
warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or
FITNESS FOR A PARTICULAR PURPOSE. The entire risk arising out of use or
performance of the Software remains with the Licensee.
* **7.2** In no event shall the Licensor be liable for any damages whatsoever
(including, without limitation, damages for loss of business profits, business
interruption, loss of business information, or any other pecuniary loss)
arising out of the Licensee’s Content, the use of or inability to use the
Software, even if the Licensor has been advised of the possibility of such
damages.
* **7.3** The Licensee is solely responsible for determining the
appropriateness of the Software and assumes any risks associated with
Licensee’s exercise of permissions under the License.
* **7.4** Third-party content or services are not covered by this License. The
Licensee shall ensure Licensee’s compliance with any terms set forth by the
respective third parties at its own risk, cost and expense. To the maximum
extent permitted by law, the Licensor excludes any liability for any loss or
damage resulting from the acts and omissions of such third-party service
providers.
# 8. Governing Law AND DISPUTE RESOLUTION
* 8.1 This License and any disputes arising out of or in connection with the
License and the Software shall be governed by and construed in accordance with
the laws of England and Wales. Unless otherwise provided by the applicable
law, all disputes arising out of or in connection with the License shall be
submitted to the exclusive jurisdiction of the courts in London, the United
Kingdom.
# 9. FINAL PROVISIONS
* **9.1** **Indemnification.** The Licensee shall indemnify the Licensor at
Licensee’s expense if any claims are asserted by a third party against the
Licensor by reason of Licensee’s misconduct or breach of any terms of the
License, including failed adherence by the Licensee with any applicable laws,
including, whether express or implied.
* **9.2** **Severability.** The unenforceability of any single provision of
this License shall not affect any other provision hereof. Where such a
provision is held to be unenforceable, the Parties shall use their best
endeavours to negotiate and agree upon an enforceable provision, which
achieves, to the greatest extent possible, the economic, legal and commercial
objectives of the unenforceable provision.
* **9.3** **Waiver.** A failure of either Party to enforce strictly a provision
of this License shall in no event be considered a waiver of any part of such
provision. No waiver by either Party of any breach or default by the other
party shall operate as a waiver of any succeeding breach or other default or
breach by such other Party. No waiver shall have any effect unless it is
specific, irrevocable and in writing.
* **9.4** **Term and termination.** The License shall commence upon Licensee’s
access to the Software and continue until terminated by the Licensor. Upon
termination of the License, the Licensee agrees to (i) stop all access and use
of the Software and (ii) destroy all copies of the Software and all its
component parts (if any) stored on the Devices. The provisions of the License
that, by their nature, continue and survive will survive any termination of
the License.
* **9.5** **Amendments.** The Licensor reserves the right, at its sole
discretion, to change or modify this License at any time by sending a prior
notification to the Licensee (if the contact details of the Licensee are
available to the Licensor). Any modifications to the License shall become
effective on the date indicated at the top of the amended License. By
continuing to use the Software after the date on which the modifications were
communicated, the Licensee agrees to be bound by the modified License. The
Licensor reserves the right to change or discontinue the Software and any
feature thereof with or without a prior notice to the Licensee.
* **9.6** **Entire agreement.** This License constitutes the entire
understanding between the Parties with respect to the subject matter thereof
and supersedes all prior agreements, negotiations and discussions between the
Parties relating thereto.
* **9.7** **Transfer of rights.** The Licensee is not allowed to assign
Licensee’s rights under the License. The Licensor is entitled to transfer its
rights and obligations under the License entirely or partially to a third
party by giving a prior notice to the Licensee. If the Licensee does not agree
to the transfer, the Licensee can terminate this License by ceasing to use the
Software.
* **9.8** **Contact.** For general enquiries and commercial licenses to use the
Software, please contact the Licensor directly.
***

View File

@@ -157,10 +157,4 @@ From `packages/server`, run `npm run start-dev`
# License
Copyright (c) 2017-2021 Laurent Cozic
Personal Use License
Joplin Server is available for personal use only. For example you may host the software on your own server for non-commercial activity.
To obtain a license for commercial purposes, please contact us.
See LICENSE.md in this directory

View File

@@ -16,4 +16,6 @@ module.exports = {
'jest-expect-message',
`${__dirname}/jest.setup.js`,
],
bail: true,
};

View File

@@ -61,7 +61,8 @@ ul li {
list-style-type: disc;
}
ul.pagination-list li {
ul.pagination-list li,
ul.menu-list li {
list-style-type: none;
}

View File

@@ -0,0 +1,116 @@
# Individual Contributor License Agreement
Thank you for your interest in Joplin Server, owned by Cozic Ltd (the
"Company"). In order to clarify the intellectual property license granted with
Contributions from any person or entity, the Company must have a Contributor
License Agreement ("CLA") on file that has been signed by each Contributor,
indicating agreement to the license terms below. This license is for your
protection as a Contributor as well as the protection of the Company and its
users; it does not change your rights to use your own Contributions for any
other purpose.
Please complete and sign this Agreement, and then email a copy to
cla@joplinapp.org only (do not copy any other persons or lists). Read this
document carefully before signing and keep a copy for your records.
- Full name: **FULL NAME**
- Postal Address: **POSTAL ADDRESS**
- Country: **COUNTRY**
- E-Mail: **EMAIL**
- GitHub username: **GITHUB USERNAME**
You accept and agree to the following terms and conditions for Your present and
future Contributions submitted to the Company. In return, the Company shall not
use Your Contributions in a way that is contrary to the public benefit or
inconsistent with its bylaws in effect at the time of the Contribution. Except
for the license granted herein to the Company and recipients of software
distributed by the Company, You reserve all right, title, and interest in and to
Your Contributions.
1. Definitions.
* "You" (or "Your")
"You" (or "Your") shall mean the copyright owner or legal entity authorized
by the copyright owner that is making this Agreement with the Company. For
legal entities, the entity making a Contribution and all other entities
that control, are controlled by, or are under common control with that
entity are considered to be a single Contributor. For the purposes of this
definition, "control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or otherwise,
or (ii) ownership of fifty percent (50%) or more of the outstanding shares,
or (iii) beneficial ownership of such entity.
* "Contribution"
"Contribution" shall mean any original work of authorship, including any
modifications or additions to an existing work, that is intentionally
submitted by You to the Company for inclusion in, or documentation of, any
of the products owned or managed by the Company (the "Work"). For the
purposes of this definition, "submitted" means any form of electronic,
verbal, or written communication sent to the Company or its
representatives, including but not limited to communication on electronic
mailing lists, source code control systems, and issue tracking systems that
are managed by, or on behalf of, the Company for the purpose of discussing
and improving the Work, but excluding communication that is conspicuously
marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License. Subject to the terms and conditions of this
Agreement, You hereby grant to the Company and to recipients of software
distributed by the Company a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable copyright license to reproduce, prepare derivative
works of, publicly display, publicly perform, sublicense, and distribute Your
Contributions and such derivative works.
3. Grant of Patent License. Subject to the terms and conditions of this
Agreement, You hereby grant to the Company and to recipients of software
distributed by the Company a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license
to make, have made, use, offer to sell, sell, import, and otherwise transfer
the Work, where such license applies only to those patent claims licensable
by You that are necessarily infringed by Your Contribution(s) alone or by
combination of Your Contribution(s) with the Work to which such
Contribution(s) was submitted. If any entity institutes patent litigation
against You or any other entity (including a cross-claim or counterclaim in a
lawsuit) alleging that your Contribution, or the Work to which you have
contributed, constitutes direct or contributory patent infringement, then any
patent licenses granted to that entity under this Agreement for that
Contribution or Work shall terminate as of the date such litigation is filed.
4. You represent that you are legally entitled to grant the above license. If
your employer(s) has rights to intellectual property that you create that
includes your Contributions, you represent that you have received permission
to make Contributions on behalf of that employer, that your employer has
waived such rights for your Contributions to the Company, or that your
employer has executed a separate Corporate CLA with the Company.
5. You represent that each of Your Contributions is Your original creation (see
section 7 for submissions on behalf of others). You represent that Your
Contribution submissions include complete details of any third-party license
or other restriction (including, but not limited to, related patents and
trademarks) of which you are personally aware and which are associated with
any part of Your Contributions.
6. You are not expected to provide support for Your Contributions, except to the
extent You desire to provide support. You may provide support for free, for a
fee, or not at all. Unless required by applicable law or agreed to in
writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT,
MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may
submit it to the Company separately from any Contribution, identifying the
complete details of its source and of any license or other restriction
(including, but not limited to, related patents, trademarks, and license
agreements) of which you are personally aware, and conspicuously marking the
work as "Submitted on behalf of a third-party: **NAME HERE**".
8. You agree to notify the Company of any facts or circumstances of which you
become aware that would make these representations inaccurate in any respect.
Please sign: **SIGNATURE** Date: **DATE**

View File

@@ -24,6 +24,7 @@ import { RouteResponseFormat, routeResponseFormat } from './utils/routeUtils';
import { parseEnv } from './env';
import storageConnectionCheck from './utils/storageConnectionCheck';
import { setLocale } from '@joplin/lib/locale';
import checkAdminHandler from './middleware/checkAdminHandler';
interface Argv {
env?: Env;
@@ -160,7 +161,7 @@ async function main() {
});
} catch (anotherError) {
ctx.response.set('Content-Type', 'application/json');
ctx.body = JSON.stringify({ error: error.message });
ctx.body = JSON.stringify({ error: `${error.message} (Check the server log for more information)` });
}
} else {
ctx.response.set('Content-Type', 'application/json');
@@ -196,6 +197,7 @@ async function main() {
app.use(apiVersionHandler);
app.use(ownerHandler);
app.use(checkAdminHandler);
app.use(notificationHandler);
app.use(clickJackingHandler);
app.use(routeHandler);

View File

@@ -66,7 +66,7 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
enabled: env.MAILER_ENABLED,
host: env.MAILER_HOST,
port: env.MAILER_PORT,
secure: env.MAILER_SECURE,
security: env.MAILER_SECURITY,
authUser: env.MAILER_AUTH_USER,
authPassword: env.MAILER_AUTH_PASSWORD,
noReplyName: env.MAILER_NOREPLY_NAME,
@@ -120,6 +120,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
stripe: stripeConfigFromEnv(stripePublicConfig, env),
port: appPort,
baseUrl,
adminBaseUrl: `${baseUrl}/admin`,
showErrorStackTraces: env.ERROR_STACK_TRACES,
apiBaseUrl,
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,

View File

@@ -0,0 +1,46 @@
import { afterAllTests, beforeAllDb, beforeEachDb, expectThrow } from './utils/testing/testUtils';
import { parseEnv } from './env';
describe('env', function() {
beforeAll(async () => {
await beforeAllDb('env');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
it('should parse env values', async function() {
const result = parseEnv({
DB_CLIENT: 'pg',
POSTGRES_PORT: '123',
MAILER_ENABLED: 'true',
SIGNUP_ENABLED: 'false',
TERMS_ENABLED: '0',
ACCOUNT_TYPES_ENABLED: '1',
});
expect(result.DB_CLIENT).toBe('pg');
expect(result.POSTGRES_PORT).toBe(123);
expect(result.MAILER_ENABLED).toBe(true);
expect(result.SIGNUP_ENABLED).toBe(false);
expect(result.TERMS_ENABLED).toBe(false);
expect(result.ACCOUNT_TYPES_ENABLED).toBe(true);
});
it('should overrides default values', async function() {
expect(parseEnv({}).POSTGRES_USER).toBe('joplin');
expect(parseEnv({}, { POSTGRES_USER: 'other' }).POSTGRES_USER).toBe('other');
});
it('should validate values', async function() {
await expectThrow(async () => parseEnv({ POSTGRES_PORT: 'notanumber' }));
await expectThrow(async () => parseEnv({ MAILER_ENABLED: 'TRUE' }));
});
});

View File

@@ -1,7 +1,13 @@
// The possible env variables and their defaults are listed below.
//
// The env variables can be of type string, integer or boolean. When the type is
// boolean, set the variable to "0" or "1" in your env file.
// boolean, set the variable to "true", "false", "0" or "1" in your env file.
export enum MailerSecurity {
None = 'none',
Tls = 'tls',
Starttls = 'starttls',
}
const defaultEnvValues: EnvVariables = {
// ==================================================
@@ -66,8 +72,8 @@ const defaultEnvValues: EnvVariables = {
MAILER_ENABLED: false,
MAILER_HOST: '',
MAILER_PORT: 587,
MAILER_SECURE: true,
MAILER_PORT: 465,
MAILER_SECURITY: MailerSecurity.Tls,
MAILER_AUTH_USER: '',
MAILER_AUTH_PASSWORD: '',
MAILER_NOREPLY_NAME: '',
@@ -120,7 +126,7 @@ export interface EnvVariables {
MAILER_ENABLED: boolean;
MAILER_HOST: string;
MAILER_PORT: number;
MAILER_SECURE: boolean;
MAILER_SECURITY: MailerSecurity;
MAILER_AUTH_USER: string;
MAILER_AUTH_PASSWORD: string;
MAILER_NOREPLY_NAME: string;
@@ -134,7 +140,13 @@ export interface EnvVariables {
STRIPE_WEBHOOK_SECRET: string;
}
export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariables {
const parseBoolean = (s: string): boolean => {
if (s === 'true' || s === '1') return true;
if (s === 'false' || s === '0') return false;
throw new Error(`Invalid boolean value: "${s}" (Must be one of "true", "false", "0, "1")`);
};
export function parseEnv(rawEnv: Record<string, string>, defaultOverrides: any = null): EnvVariables {
const output: EnvVariables = {
...defaultEnvValues,
...defaultOverrides,
@@ -145,17 +157,21 @@ export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariable
if (rawEnvValue === undefined) continue;
if (typeof value === 'number') {
const v = Number(rawEnvValue);
if (isNaN(v)) throw new Error(`Invalid number value for env variable ${key} = ${rawEnvValue}`);
(output as any)[key] = v;
} else if (typeof value === 'boolean') {
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean value for env variable ${key}: ${rawEnvValue} (Should be either "0" or "1")`);
(output as any)[key] = rawEnvValue === '1';
} else if (typeof value === 'string') {
(output as any)[key] = `${rawEnvValue}`;
} else {
throw new Error(`Invalid env default value type: ${typeof value}`);
try {
if (typeof value === 'number') {
const v = Number(rawEnvValue);
if (isNaN(v)) throw new Error(`Invalid number value "${rawEnvValue}"`);
(output as any)[key] = v;
} else if (typeof value === 'boolean') {
(output as any)[key] = parseBoolean(rawEnvValue);
} else if (typeof value === 'string') {
(output as any)[key] = `${rawEnvValue}`;
} else {
throw new Error(`Invalid env default value type: ${typeof value}`);
}
} catch (error) {
error.message = `Could not parse key "${key}": ${error.message}`;
throw error;
}
}

View File

@@ -0,0 +1,58 @@
import { ErrorForbidden } from '../utils/errors';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext, expectNotThrow, expectHttpError, createUserAndSession } from '../utils/testing/testUtils';
import checkAdminHandler from './checkAdminHandler';
describe('checkAdminHandler', function() {
beforeAll(async () => {
await beforeAllDb('checkAdminHandler');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should access /admin if the user is admin', async function() {
const { session } = await createUserAndSession(1, true);
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'GET',
url: '/admin/organizations',
},
});
await expectNotThrow(async () => checkAdminHandler(context, koaNext));
});
test('should not access /admin if the user is not admin', async function() {
const { session } = await createUserAndSession(1);
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'GET',
url: '/admin/organizations',
},
});
await expectHttpError(async () => checkAdminHandler(context, koaNext), ErrorForbidden.httpCode);
});
test('should not access /admin if the user is not logged in', async function() {
const context = await koaAppContext({
request: {
method: 'GET',
url: '/admin/organizations',
},
});
await expectHttpError(async () => checkAdminHandler(context, koaNext), ErrorForbidden.httpCode);
});
});

View File

@@ -0,0 +1,12 @@
import { AppContext, KoaNext } from '../utils/types';
import { isAdminRequest } from '../utils/requestUtils';
import { ErrorForbidden } from '../utils/errors';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
if (isAdminRequest(ctx)) {
if (!ctx.joplin.owner) throw new ErrorForbidden();
if (!ctx.joplin.owner.is_admin) throw new ErrorForbidden();
}
return next();
}

View File

@@ -4,7 +4,7 @@ import { isView, View } from '../services/MustacheService';
import config from '../config';
import { userIp } from '../utils/requestUtils';
import { createCsrfTag } from '../utils/csrf';
import { getImpersonatorAdminSessionId } from '../routes/index/utils/users/impersonate';
import { getImpersonatorAdminSessionId } from '../routes/admin/utils/users/impersonate';
export default async function(ctx: AppContext) {
const requestStartTime = Date.now();
@@ -20,6 +20,7 @@ export default async function(ctx: AppContext) {
const view = responseObject as View;
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
currentUrl: ctx.URL,
notifications: ctx.joplin.notifications || [],
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
owner: ctx.joplin.owner,

View File

@@ -92,7 +92,8 @@ export default class NotificationModel extends BaseModel<Notification> {
return this.add(userId, NotificationKey.Any, NotificationLevel.Normal, message);
}
public async addError(userId: Uuid, message: string) {
public async addError(userId: Uuid, error: string | Error) {
const message = typeof error === 'string' ? error : error.message;
return this.add(userId, NotificationKey.Any, NotificationLevel.Error, message);
}

View File

@@ -0,0 +1,14 @@
import { _ } from '@joplin/lib/locale';
import defaultView from '../../utils/defaultView';
import Router from '../../utils/Router';
import { SubPath } from '../../utils/routeUtils';
import { AppContext, RouteType } from '../../utils/types';
const router = new Router(RouteType.Web);
router.get('admin/dashboard', async (_path: SubPath, _ctx: AppContext) => {
const view = defaultView('admin/dashboard', _('Admin dashboard'));
return view;
});
export default router;

View File

@@ -16,7 +16,7 @@ const prettyCron = require('prettycron');
const router: Router = new Router(RouteType.Web);
router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
router.post('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@@ -52,7 +52,7 @@ router.post('tasks', async (_path: SubPath, ctx: AppContext) => {
return redirect(ctx, makeUrl(UrlType.Tasks));
});
router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@@ -126,13 +126,13 @@ router.get('tasks', async (_path: SubPath, ctx: AppContext) => {
};
return {
...defaultView('tasks', 'Tasks'),
...defaultView('admin/tasks', 'Tasks'),
content: {
itemTable: makeTableView(table),
postUrl: makeUrl(UrlType.Tasks),
csrfTag: await createCsrfTag(ctx),
},
cssFiles: ['index/tasks'],
// cssFiles: ['index/tasks'],
};
});

View File

@@ -8,13 +8,13 @@ import { yesOrNo } from '../../utils/strings';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
import { formatDateTime } from '../../utils/time';
import { userDeletionsUrl, userUrl } from '../../utils/urlUtils';
import { adminUserDeletionsUrl, userUrl } from '../../utils/urlUtils';
import { createCsrfTag } from '../../utils/csrf';
import { bodyFields } from '../../utils/requestUtils';
const router: Router = new Router(RouteType.Web);
router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();
@@ -26,7 +26,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
console.info(page);
const table: Table = {
baseUrl: userDeletionsUrl(),
baseUrl: adminUserDeletionsUrl(),
requestQuery: ctx.query,
pageCount: page.page_count,
pagination,
@@ -110,7 +110,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
}),
};
const view = defaultView('user_deletions', 'User deletions');
const view = defaultView('admin/user_deletions', 'User deletions');
view.content = {
userDeletionTable: makeTableView(table),
postUrl: makeUrl(UrlType.UserDeletions),
@@ -124,7 +124,7 @@ router.get('user_deletions', async (_path: SubPath, ctx: AppContext) => {
throw new ErrorMethodNotAllowed();
});
router.post('user_deletions', async (_path: SubPath, ctx: AppContext) => {
router.post('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
const user = ctx.joplin.owner;
if (!user.is_admin) throw new ErrorForbidden();

View File

@@ -0,0 +1,183 @@
import { User } from '../../services/database/types';
import routeHandler from '../../middleware/routeHandler';
import { execRequest } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
import { ErrorForbidden } from '../../utils/errors';
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
password = password === null ? uuidgen() : password;
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/admin/users/new',
body: {
email: email,
password: password,
password2: password,
post_button: true,
...props,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: url ? url : '/admin/users',
body: {
...user,
post_button: true,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
describe('admin/users', function() {
beforeAll(async () => {
await beforeAllDb('admin/users');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, 'test@example.com', password, {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
expect(newUser.max_item_size).toBe(null);
expect(newUser.must_set_password).toBe(0);
const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === password).toBe(false); // Password has been hashed
});
test('should create a user with null properties if they are not explicitly set', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com');
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.max_item_size).toBe(null);
expect(newUser.can_share_folder).toBe(null);
expect(newUser.can_share_note).toBe(null);
expect(newUser.max_total_item_size).toBe(null);
});
test('should ask user to set password if not set on creation', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com', '', {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.must_set_password).toBe(1);
expect(!!newUser.password).toBe(true);
});
test('should format the email when saving it', async function() {
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, email, password);
const loggedInUser = await models().user().login(email, password);
expect(!!loggedInUser).toBe(true);
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
});
test('should not create anything if user creation fail', async function() {
const { session } = await createUserAndSession(1, true);
const userModel = models().user();
const password = uuidgen();
await postUser(session.id, 'test@example.com', password);
const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2);
try {
await postUser(session.id, 'test@example.com', password);
} catch {
// Ignore
}
const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount);
});
test('should list users', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1, true);
const { user: user2 } = await createUserAndSession(2, false);
const result = await execRequest(session1.id, 'GET', 'admin/users');
expect(result).toContain(user1.email);
expect(result).toContain(user2.email);
});
test('should delete sessions when changing password', async function() {
const { user, session, password } = await createUserAndSession(1);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
expect(await models().session().count()).toBe(4);
await patchUser(session.id, {
id: user.id,
email: 'changed@example.com',
password: 'hunter11hunter22hunter33',
password2: 'hunter11hunter22hunter33',
}, '/admin/users/me');
const sessions = await models().session().all();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(session.id);
});
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
// admin user cannot make themselves a non-admin
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
// cannot delete own user
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `admin/users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
});
});

View File

@@ -0,0 +1,311 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
import uuidgen from '../../utils/uuidgen';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
import { stripePortalUrl, adminUserDeletionsUrl, adminUserUrl } from '../../utils/urlUtils';
import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime, Hour } from '../../utils/time';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
import { _ } from '@joplin/lib/locale';
export interface CheckRepeatPasswordInput {
password: string;
password2: string;
}
export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
} else {
if (required) throw new ErrorUnprocessableEntity('Password is required');
}
return '';
}
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function makeUser(isNew: boolean, fields: any): User {
const user: User = {};
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin;
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
if ('account_type' in fields) user.account_type = Number(fields.account_type);
const password = checkRepeatPassword(fields, false);
if (password) user.password = password;
if (!isNew) user.id = fields.id;
if (isNew) {
user.must_set_password = user.password ? 0 : 1;
user.password = user.password ? user.password : uuidgen();
}
return user;
}
function defaultUser(): User {
return {};
}
function userIsNew(path: SubPath): boolean {
return path.id === 'new';
}
function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
const router = new Router(RouteType.Web);
router.get('admin/users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.joplin.models.user();
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
const users = await userModel.all();
users.sort((u1: User, u2: User) => {
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
if (u1.full_name && !u2.full_name) return +1;
if (!u1.full_name && u2.full_name) return -1;
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
});
const view: View = defaultView('admin/users', _('Users'));
view.content = {
users: users.map(user => {
return {
...user,
url: adminUserUrl(user.id),
displayName: user.full_name ? user.full_name : '(not set)',
formattedItemMaxSize: formatMaxItemSize(user),
formattedTotalSize: formatTotalSize(user),
formattedMaxTotalSize: formatMaxTotalSize(user),
formattedTotalSizePercent: formatTotalSizePercent(user),
totalSizeClass: totalSizeClass(user),
formattedAccountType: accountTypeToString(user.account_type),
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
rowClassName: user.enabled ? '' : 'is-disabled',
};
}),
};
return view;
});
router.get('admin/users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
const owner = ctx.joplin.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const models = ctx.joplin.models;
const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await models.user().load(userId) : user;
if (isNew && !user) user = defaultUser();
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
let postUrl = '';
if (isNew) {
postUrl = adminUserUrl('new');
} else if (isMe) {
postUrl = adminUserUrl('me');
} else {
postUrl = adminUserUrl(user.id);
}
interface UserFlagView extends UserFlag {
message: string;
}
const userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
return {
...f,
message: userFlagToString(f),
};
});
userFlagViews.sort((a, b) => {
return a.created_time < b.created_time ? +1 : -1;
});
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
const view: View = defaultView('admin/user', _('Profile'));
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? _('Create user') : _('Update profile');
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDisableButton = !isNew && owner.id !== user.id && user.enabled;
view.content.csrfTag = await createCsrfTag(ctx);
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
view.content.subscription = subscription;
view.content.showManageSubscription = !isNew;
view.content.showUpdateSubscriptionBasic = !isNew && user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
}
view.content.showImpersonateButton = !isNew && user.enabled && user.id !== owner.id;
view.content.showRestoreButton = !isNew && !user.enabled;
view.content.showScheduleDeletionButton = !isNew && !isScheduledForDeletion;
view.content.showResetPasswordButton = !isNew && user.enabled;
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
view.content.hasFlags = !!userFlagViews.length;
view.content.userFlagViews = userFlagViews;
view.content.stripePortalUrl = stripePortalUrl();
view.content.pageTitle = view.content.buttonTitle;
view.jsFiles.push('zxcvbn');
view.cssFiles.push('index/user');
if (config().accountTypesEnabled) {
view.content.showAccountTypes = true;
view.content.accountTypes = accountTypeOptions().map((o: any) => {
o.selected = user.account_type === o.value;
return o;
});
}
return view;
});
router.alias(HttpMethod.POST, 'admin/users/:id', 'admin/users');
interface FormFields {
id: Uuid;
post_button: string;
disable_button: string;
restore_button: string;
cancel_subscription_button: string;
send_account_confirmation_email: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
impersonate_button: string;
stop_impersonate_button: string;
delete_user_flags: string;
schedule_deletion_button: string;
}
router.post('admin/users', async (path: SubPath, ctx: AppContext) => {
let user: User = {};
const owner = ctx.joplin.owner;
let userId = userIsMe(path) ? owner.id : path.id;
try {
const body = await formParse(ctx.req);
const fields = body.fields as FormFields;
const isNew = userIsNew(path);
if (userIsMe(path)) fields.id = userId;
user = makeUser(isNew, fields);
const models = ctx.joplin.models;
if (fields.post_button) {
const userToSave: User = models.user().fromApiInput(user);
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
if (isNew) {
const savedUser = await models.user().save(userToSave);
userId = savedUser.id;
} else {
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
}
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);
} else if (fields.disable_button || fields.restore_button) {
const user = await models.user().load(path.id);
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
} else if (fields.send_account_confirmation_email) {
const user = await models.user().load(path.id);
await models.user().save({ id: user.id, must_set_password: 1 });
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.impersonate_button) {
await startImpersonating(ctx, userId);
return redirect(ctx, config().baseUrl);
} else if (fields.cancel_subscription_button) {
await cancelSubscriptionByUserId(models, userId);
} else if (fields.update_subscription_basic_button) {
await updateSubscriptionType(models, userId, AccountType.Basic);
} else if (fields.update_subscription_pro_button) {
await updateSubscriptionType(models, userId, AccountType.Pro);
} else if (fields.schedule_deletion_button) {
const deletionDate = Date.now() + 24 * Hour;
await models.userDeletion().add(userId, deletionDate, {
processAccount: true,
processData: true,
});
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${adminUserDeletionsUrl()})`);
} else if (fields.delete_user_flags) {
const userFlagTypes: UserFlagType[] = [];
for (const key of Object.keys(fields)) {
if (key.startsWith('user_flag_')) {
const type = Number(key.substr(10));
userFlagTypes.push(type);
}
}
await models.userFlag().removeMulti(userId, userFlagTypes);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, adminUserUrl(userIsMe(path) ? '/me' : `/${userId}`));
} catch (error) {
error.message = `Error: Your changes were not saved: ${error.message}`;
if (error instanceof ErrorForbidden) throw error;
const endPoint = router.findEndPoint(HttpMethod.GET, 'admin/users/:id');
return endPoint.handler(path, ctx, user, error);
}
});
export default router;

View File

@@ -34,6 +34,7 @@ router.get('api/users/:id/public_key', async (path: SubPath, ctx: AppContext) =>
if (!user) return ''; // Don't throw an error to prevent polling the end point
const ppk = await ctx.joplin.models.user().publicPrivateKey(user.id);
if (!ppk) return '';
return {
id: ppk.id,

View File

@@ -1,10 +1,10 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_home', function() {
describe('index/home', function() {
beforeAll(async () => {
await beforeAllDb('index_home');
await beforeAllDb('index/home');
});
afterAll(async () => {

View File

@@ -7,14 +7,14 @@ import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError, expectThrow } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
export async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
async function postUser(sessionId: string, email: string, password: string = null, props: any = null): Promise<User> {
password = password === null ? uuidgen() : password;
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/users/new',
url: '/admin/users/new',
body: {
email: email,
password: password,
@@ -30,7 +30,7 @@ export async function postUser(sessionId: string, email: string, password: strin
return context.response.body;
}
export async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
async function patchUser(sessionId: string, user: any, url: string = ''): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@@ -48,7 +48,7 @@ export async function patchUser(sessionId: string, user: any, url: string = ''):
return context.response.body;
}
export async function getUserHtml(sessionId: string, userId: string): Promise<string> {
async function getUserHtml(sessionId: string, userId: string): Promise<string> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@@ -76,53 +76,6 @@ describe('index/users', function() {
await beforeEachDb();
});
test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, 'test@example.com', password, {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
expect(newUser.max_item_size).toBe(null);
expect(newUser.must_set_password).toBe(0);
const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === password).toBe(false); // Password has been hashed
});
test('should create a user with null properties if they are not explicitly set', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com');
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.max_item_size).toBe(null);
expect(newUser.can_share_folder).toBe(null);
expect(newUser.can_share_note).toBe(null);
expect(newUser.max_total_item_size).toBe(null);
});
test('should ask user to set password if not set on creation', async function() {
const { session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com', '', {
max_item_size: '',
});
const newUser = await models().user().loadByEmail('test@example.com');
expect(newUser.must_set_password).toBe(1);
expect(!!newUser.password).toBe(true);
});
test('new user should be able to login', async function() {
const { session } = await createUserAndSession(1, true);
@@ -133,39 +86,6 @@ describe('index/users', function() {
expect(loggedInUser.email).toBe('test@example.com');
});
test('should format the email when saving it', async function() {
const email = 'ILikeUppercaseAndSpaces@Example.COM ';
const { session } = await createUserAndSession(1, true);
const password = uuidgen();
await postUser(session.id, email, password);
const loggedInUser = await models().user().login(email, password);
expect(!!loggedInUser).toBe(true);
expect(loggedInUser.email).toBe('ilikeuppercaseandspaces@example.com');
});
test('should not create anything if user creation fail', async function() {
const { session } = await createUserAndSession(1, true);
const userModel = models().user();
const password = uuidgen();
await postUser(session.id, 'test@example.com', password);
const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2);
try {
await postUser(session.id, 'test@example.com', password);
} catch {
// Ignore
}
const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount);
});
test('should change user properties', async function() {
const { user, session } = await createUserAndSession(1, false);
@@ -198,15 +118,6 @@ describe('index/users', function() {
expect((doc.querySelector('input[name=email]') as any).value).toBe('user1@localhost');
});
test('should list users', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1, true);
const { user: user2 } = await createUserAndSession(2, false);
const result = await execRequest(session1.id, 'GET', 'users');
expect(result).toContain(user1.email);
expect(result).toContain(user2.email);
});
test('should allow user to set a password for new accounts', async function() {
let user1 = await models().user().save({
email: 'user1@localhost',
@@ -366,33 +277,31 @@ describe('index/users', function() {
await expectThrow(async () => execRequest('', 'GET', path, null, { query: { token } }));
});
test('should delete sessions when changing password', async function() {
const { user, session, password } = await createUserAndSession(1);
test('should not change non-whitelisted properties', async () => {
const { user: user1, session: session1 } = await createUserAndSession(2, false);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
await models().session().authenticate(user.email, password);
expect(await models().session().count()).toBe(4);
await patchUser(session.id, {
id: user.id,
email: 'changed@example.com',
password: 'hunter11hunter22hunter33',
password2: 'hunter11hunter22hunter33',
}, '/users/me');
const sessions = await models().session().all();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe(session.id);
await patchUser(session1.id, {
id: user1.id,
is_admin: 1,
max_item_size: 555,
max_total_item_size: 5555,
can_share_folder: 1,
can_upload: 0,
});
const reloadedUser1 = await models().user().load(user1.id);
expect(reloadedUser1.is_admin).toBe(0);
expect(reloadedUser1.max_item_size).toBe(null);
expect(reloadedUser1.max_total_item_size).toBe(null);
expect(reloadedUser1.can_share_folder).toBe(null);
expect(reloadedUser1.can_upload).toBe(1);
});
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: session1 } = await createUserAndSession(2, false);
const { user: admin } = await createUserAndSession(1, true);
const { session: session1 } = await createUserAndSession(2, false);
// non-admin cannot list users
await expectHttpError(async () => execRequest(session1.id, 'GET', 'users'), ErrorForbidden.httpCode);
await expectHttpError(async () => execRequest(session1.id, 'GET', 'admin/users'), ErrorForbidden.httpCode);
// non-admin user cannot view another user
await expectHttpError(async () => execRequest(session1.id, 'GET', `users/${admin.id}`), ErrorForbidden.httpCode);
@@ -402,30 +311,6 @@ describe('index/users', function() {
// non-admin user cannot update another user
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, email: 'cantdothateither@example.com' }), ErrorForbidden.httpCode);
// non-admin user cannot make themself an admin
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, is_admin: 1 }), ErrorForbidden.httpCode);
// admin user cannot make themselves a non-admin
await expectHttpError(async () => patchUser(adminSession.id, { id: admin.id, is_admin: 0 }), ErrorForbidden.httpCode);
// only admins can delete users
// Note: Disabled because the entire code is skipped if it's not an admin
// await expectHttpError(async () => execRequest(session1.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// cannot delete own user
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { disable_button: true }), ErrorForbidden.httpCode);
// non-admin cannot change max_item_size
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_item_size: 1000 }), ErrorForbidden.httpCode);
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, max_total_item_size: 1000 }), ErrorForbidden.httpCode);
// non-admin cannot change can_share_folder
await models().user().save({ id: user1.id, can_share_folder: 0 });
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_share_folder: 1 }), ErrorForbidden.httpCode);
// non-admin cannot change non-whitelisted properties
await expectHttpError(async () => patchUser(session1.id, { id: user1.id, can_upload: 0 }), ErrorForbidden.httpCode);
});

View File

@@ -4,24 +4,21 @@ import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types';
import { User, UserFlag, Uuid } from '../../services/database/types';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
import uuidgen from '../../utils/uuidgen';
import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings';
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select';
import { confirmUrl, stripePortalUrl, userDeletionsUrl } from '../../utils/urlUtils';
import { cancelSubscriptionByUserId, updateCustomerEmail, updateSubscriptionType } from '../../utils/stripe';
import { AccountType, accountTypeOptions } from '../../models/UserModel';
import { confirmUrl, stripePortalUrl } from '../../utils/urlUtils';
import { updateCustomerEmail } from '../../utils/stripe';
import { createCsrfTag } from '../../utils/csrf';
import { formatDateTime, Hour } from '../../utils/time';
import { formatDateTime } from '../../utils/time';
import { cookieSet } from '../../utils/cookies';
import { startImpersonating, stopImpersonating } from './utils/users/impersonate';
import { userFlagToString } from '../../models/UserFlagModel';
import { stopImpersonating } from '../admin/utils/users/impersonate';
import { _ } from '@joplin/lib/locale';
export interface CheckRepeatPasswordInput {
password: string;
@@ -39,120 +36,40 @@ export function checkRepeatPassword(fields: CheckRepeatPasswordInput, required:
return '';
}
function boolOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output) || (output !== 0 && output !== 1)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function intOrDefaultToValue(fields: any, fieldName: string): number | null {
if (fields[fieldName] === '') return null;
const output = Number(fields[fieldName]);
if (isNaN(output)) throw new Error(`Invalid value for ${fieldName}`);
return output;
}
function makeUser(isNew: boolean, fields: any): User {
function makeUser(userId: Uuid, fields: any): User {
const user: User = {};
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin;
if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size');
if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size');
if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder');
if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload');
if ('account_type' in fields) user.account_type = Number(fields.account_type);
const password = checkRepeatPassword(fields, false);
if (password) user.password = password;
if (!isNew) user.id = fields.id;
if (isNew) {
user.must_set_password = user.password ? 0 : 1;
user.password = user.password ? user.password : uuidgen();
}
user.id = userId;
return user;
}
function defaultUser(): User {
return {};
}
function userIsNew(path: SubPath): boolean {
return path.id === 'new';
}
function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
const router = new Router(RouteType.Web);
router.get('users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.joplin.models.user();
await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.List);
const users = await userModel.all();
users.sort((u1: User, u2: User) => {
if (u1.full_name && u2.full_name) return u1.full_name.toLowerCase() < u2.full_name.toLowerCase() ? -1 : +1;
if (u1.full_name && !u2.full_name) return +1;
if (!u1.full_name && u2.full_name) return -1;
return u1.email.toLowerCase() < u2.email.toLowerCase() ? -1 : +1;
});
const view: View = defaultView('users', 'Users');
view.content = {
users: users.map(user => {
return {
...user,
displayName: user.full_name ? user.full_name : '(not set)',
formattedItemMaxSize: formatMaxItemSize(user),
formattedTotalSize: formatTotalSize(user),
formattedMaxTotalSize: formatMaxTotalSize(user),
formattedTotalSizePercent: formatTotalSizePercent(user),
totalSizeClass: totalSizeClass(user),
formattedAccountType: accountTypeToString(user.account_type),
formattedCanShareFolder: yesOrNo(getCanShareFolder(user)),
rowClassName: user.enabled ? '' : 'is-disabled',
};
}),
userDeletionUrl: userDeletionsUrl(),
};
return view;
});
router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
router.get('users/:id', async (path: SubPath, ctx: AppContext, formUser: User = null, error: any = null) => {
const owner = ctx.joplin.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const models = ctx.joplin.models;
const userId = userIsMe(path) ? owner.id : path.id;
if (path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
user = !isNew ? user || await models.user().load(userId) : null;
if (isNew && !user) user = defaultUser();
const models = ctx.joplin.models;
const userId = owner.id;
const user = await models.user().load(userId);
await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user);
let postUrl = '';
if (isNew) {
postUrl = `${config().baseUrl}/users/new`;
} else if (isMe) {
postUrl = `${config().baseUrl}/users/me`;
} else {
postUrl = `${config().baseUrl}/users/${user.id}`;
}
const postUrl = `${config().baseUrl}/users/me`;
interface UserFlagView extends UserFlag {
message: string;
}
let userFlagViews: UserFlagView[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => {
let userFlagViews: UserFlagView[] = (await models.userFlag().allByUserId(user.id)).map(f => {
return {
...f,
message: userFlagToString(f),
@@ -165,35 +82,25 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
if (!owner.is_admin) userFlagViews = [];
const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null;
const isScheduledForDeletion = await ctx.joplin.models.userDeletion().isScheduledForDeletion(userId);
const subscription = await ctx.joplin.models.subscription().byUserId(userId);
const view: View = defaultView('user', 'Profile');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.user = formUser ? formUser : user;
view.content.buttonTitle = _('Update profile');
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
view.content.csrfTag = await createCsrfTag(ctx);
if (subscription) {
const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription);
view.content.subscription = subscription;
view.content.showManageSubscription = !isNew;
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro;
view.content.showUpdateSubscriptionBasic = user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = user.account_type !== AccountType.Pro;
view.content.subLastPaymentStatus = lastPaymentAttempt.status;
view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time);
}
view.content.showImpersonateButton = !isNew && !!owner.is_admin && user.enabled && user.id !== owner.id;
view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled;
view.content.showScheduleDeletionButton = !isNew && !!owner.is_admin && !isScheduledForDeletion;
view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled;
view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder');
view.content.canUploadOptions = yesNoOptions(user, 'can_upload');
view.content.hasFlags = !!userFlagViews.length;
view.content.userFlagViews = userFlagViews;
view.content.stripePortalUrl = stripePortalUrl();
@@ -303,98 +210,50 @@ router.alias(HttpMethod.POST, 'users/:id', 'users');
interface FormFields {
id: Uuid;
post_button: string;
disable_button: string;
restore_button: string;
cancel_subscription_button: string;
send_account_confirmation_email: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
// user_cancel_subscription_button: string;
impersonate_button: string;
stop_impersonate_button: string;
delete_user_flags: string;
schedule_deletion_button: string;
}
router.post('users', async (path: SubPath, ctx: AppContext) => {
let user: User = {};
const owner = ctx.joplin.owner;
const userId = userIsMe(path) ? owner.id : path.id;
if (path.id && path.id !== 'me' && path.id !== owner.id) throw new ErrorForbidden();
const models = ctx.joplin.models;
let user: User = null;
try {
const body = await formParse(ctx.req);
const fields = body.fields as FormFields;
const isNew = userIsNew(path);
if (userIsMe(path)) fields.id = userId;
user = makeUser(isNew, fields);
const models = ctx.joplin.models;
if (fields.id && fields.id !== owner.id) throw new ErrorForbidden();
user = makeUser(owner.id, fields);
if (fields.post_button) {
const userToSave: User = models.user().fromApiInput(user);
await models.user().checkIfAllowed(owner, isNew ? AclAction.Create : AclAction.Update, userToSave);
await models.user().checkIfAllowed(owner, AclAction.Update, userToSave);
if (isNew) {
await models.user().save(userToSave);
} else {
if (userToSave.email && !owner.is_admin) {
await models.user().initiateEmailChange(userId, userToSave.email);
delete userToSave.email;
}
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
if (userToSave.email && userToSave.email !== owner.email) {
await models.user().initiateEmailChange(owner.id, userToSave.email);
delete userToSave.email;
}
await models.user().save(userToSave, { isNew: false });
// When changing the password, we also clear all session IDs for
// that user, except the current one (otherwise they would be
// logged out).
if (userToSave.password) await models.session().deleteByUserId(userToSave.id, contextSessionId(ctx));
} else if (fields.stop_impersonate_button) {
await stopImpersonating(ctx);
return redirect(ctx, config().baseUrl);
} else if (owner.is_admin) {
if (fields.disable_button || fields.restore_button) {
const user = await models.user().load(path.id);
await models.user().checkIfAllowed(owner, AclAction.Delete, user);
await models.userFlag().toggle(user.id, UserFlagType.ManuallyDisabled, !fields.restore_button);
} else if (fields.send_account_confirmation_email) {
const user = await models.user().load(path.id);
await models.user().save({ id: user.id, must_set_password: 1 });
await models.user().sendAccountConfirmationEmail(user);
} else if (fields.impersonate_button) {
await startImpersonating(ctx, userId);
return redirect(ctx, config().baseUrl);
} else if (fields.cancel_subscription_button) {
await cancelSubscriptionByUserId(models, userId);
} else if (fields.update_subscription_basic_button) {
await updateSubscriptionType(models, userId, AccountType.Basic);
} else if (fields.update_subscription_pro_button) {
await updateSubscriptionType(models, userId, AccountType.Pro);
} else if (fields.schedule_deletion_button) {
const deletionDate = Date.now() + 24 * Hour;
await models.userDeletion().add(userId, deletionDate, {
processAccount: true,
processData: true,
});
await models.notification().addInfo(owner.id, `User ${user.email} has been scheduled for deletion on ${formatDateTime(deletionDate)}. [View deletion list](${userDeletionsUrl()})`);
} else if (fields.delete_user_flags) {
const userFlagTypes: UserFlagType[] = [];
for (const key of Object.keys(fields)) {
if (key.startsWith('user_flag_')) {
const type = Number(key.substr(10));
userFlagTypes.push(type);
}
}
await models.userFlag().removeMulti(userId, userFlagTypes);
} else {
throw new Error('Invalid form button');
}
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : `/${userId}`}`);
return redirect(ctx, `${config().baseUrl}/users/me`);
} catch (error) {
error.message = `Error: Your changes were not saved: ${error.message}`;
if (error instanceof ErrorForbidden) throw error;

View File

@@ -5,12 +5,17 @@ import apiBatchItems from './api/batch_items';
import apiDebug from './api/debug';
import apiEvents from './api/events';
import apiItems from './api/items';
import apiLocks from './api/locks';
import apiPing from './api/ping';
import apiSessions from './api/sessions';
import apiShares from './api/shares';
import apiShareUsers from './api/share_users';
import apiUsers from './api/users';
import apiLocks from './api/locks';
import adminDashboard from './admin/dashboard';
import adminTasks from './admin/tasks';
import adminUserDeletions from './admin/user_deletions';
import adminUsers from './admin/users';
import indexChanges from './index/changes';
import indexHelp from './index/help';
@@ -24,44 +29,45 @@ import indexPrivacy from './index/privacy';
import indexShares from './index/shares';
import indexSignup from './index/signup';
import indexStripe from './index/stripe';
import indexTasks from './index/tasks';
import indexTerms from './index/terms';
import indexUpgrade from './index/upgrade';
import indexUsers from './index/users';
import indexUserDeletions from './index/user_deletions';
import defaultRoute from './default';
const routes: Routers = {
'api/batch': apiBatch,
'api/batch_items': apiBatchItems,
'api/batch': apiBatch,
'api/debug': apiDebug,
'api/events': apiEvents,
'api/items': apiItems,
'api/locks': apiLocks,
'api/ping': apiPing,
'api/sessions': apiSessions,
'api/share_users': apiShareUsers,
'api/shares': apiShares,
'api/users': apiUsers,
'api/locks': apiLocks,
'admin/dashboard': adminDashboard,
'admin/tasks': adminTasks,
'admin/user_deletions': adminUserDeletions,
'admin/users': adminUsers,
'changes': indexChanges,
'help': indexHelp,
'home': indexHome,
'items': indexItems,
'password': indexPassword,
'login': indexLogin,
'logout': indexLogout,
'notifications': indexNotifications,
'signup': indexSignup,
'password': indexPassword,
'privacy': indexPrivacy,
'shares': indexShares,
'users': indexUsers,
'signup': indexSignup,
'stripe': indexStripe,
'terms': indexTerms,
'privacy': indexPrivacy,
'upgrade': indexUpgrade,
'help': indexHelp,
'tasks': indexTasks,
'user_deletions': indexUserDeletions,
'users': indexUsers,
'': defaultRoute,
};

View File

@@ -7,6 +7,7 @@ import { Email, EmailSender } from '../services/database/types';
import { errorToString } from '../utils/errors';
import EmailModel from '../models/EmailModel';
import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils';
import { MailerSecurity } from '../env';
const logger = Logger.create('EmailService');
@@ -25,10 +26,16 @@ export default class EmailService extends BaseService {
if (!this.senderInfo(EmailSender.NoReply).email) {
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
}
// NodeMailer's TLS options are weird:
// https://nodemailer.com/smtp/#tls-options
const options: SMTPTransport.Options = {
host: this.config.mailer.host,
port: this.config.mailer.port,
secure: this.config.mailer.secure,
secure: this.config.mailer.security === MailerSecurity.Tls,
ignoreTLS: this.config.mailer.security === MailerSecurity.None,
requireTLS: this.config.mailer.security === MailerSecurity.Starttls,
};
if (this.config.mailer.authUser || this.config.mailer.authPassword) {
options.auth = {

View File

@@ -9,6 +9,19 @@ 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 { URL } from 'url';
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
export interface MenuItem {
title: string;
url?: string;
children?: MenuItem[];
selected?: boolean;
icon?: string;
selectedCondition?: MenuItemSelectedCondition;
}
export interface RenderOptions {
partials?: any;
@@ -27,6 +40,7 @@ export interface View {
partials?: string[];
cssFiles?: string[];
jsFiles?: string[];
strings?: Record<string, string>; // List of translatable strings
}
interface GlobalParams {
@@ -46,6 +60,11 @@ interface GlobalParams {
isJoplinCloud?: boolean;
impersonatorAdminSessionId?: string;
csrfTag?: string;
s?: Record<string, string>; // List of translatable strings
isAdminPage?: boolean;
adminMenu?: MenuItem[];
navbarMenu?: MenuItem[];
currentUrl?: URL;
}
export function isView(o: any): boolean {
@@ -93,6 +112,88 @@ export default class MustacheService {
return `${config().layoutDir}/${name}.mustache`;
}
private setSelectedMenu(selectedUrl: URL, menuItems: MenuItem[]) {
if (!selectedUrl) return;
if (!menuItems) return;
const url = stripOffQueryParameters(selectedUrl.href);
for (const menuItem of menuItems) {
if (menuItem.url) {
if (menuItem.selectedCondition) {
menuItem.selected = menuItem.selectedCondition(selectedUrl);
} else {
menuItem.selected = url === menuItem.url;
}
}
this.setSelectedMenu(selectedUrl, menuItem.children);
}
}
private makeAdminMenu(selectedUrl: URL): MenuItem[] {
const output: MenuItem[] = [
{
title: _('General'),
children: [
{
title: _('Dashboard'),
url: adminDashboardUrl(),
},
{
title: _('Users'),
url: adminUsersUrl(),
},
{
title: _('User deletions'),
url: adminUserDeletionsUrl(),
},
{
title: _('Tasks'),
url: adminTasksUrl(),
},
],
},
];
this.setSelectedMenu(selectedUrl, output);
return output;
}
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
let output: MenuItem[] = [
{
title: _('Home'),
url: homeUrl(),
},
];
if (isAdmin) {
output = output.concat([
{
title: _('Items'),
url: itemsUrl(),
},
{
title: _('Logs'),
url: changesUrl(),
},
{
title: _('Admin'),
url: adminDashboardUrl(),
icon: 'fas fa-hammer',
selectedCondition: (selectedUrl: URL) => {
return selectedUrl.pathname.startsWith('/admin/') || selectedUrl.pathname === '/admin';
},
},
]);
}
this.setSelectedMenu(selectedUrl, output);
return output;
}
private get defaultLayoutOptions(): GlobalParams {
return {
baseUrl: config().baseUrl,
@@ -185,13 +286,10 @@ export default class MustacheService {
globalParams = {
...this.defaultLayoutOptions,
...globalParams,
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),
};
const contentHtml = await this.renderFileContent(filePath, view, globalParams);
const layoutView: any = {
global: globalParams,
isAdminPage: view.path.startsWith('/admin/'),
s: {
home: _('Home'),
users: _('Users'),
@@ -200,7 +298,14 @@ export default class MustacheService {
tasks: _('Tasks'),
help: _('Help'),
logout: _('Logout'),
admin: _('Admin'),
},
};
const contentHtml = await this.renderFileContent(filePath, view, globalParams);
const layoutView: any = {
global: globalParams,
pageName: view.name,
pageTitle: view.titleOverride ? view.title : `${config().appName} - ${view.title}`,
contentHtml: contentHtml,

View File

@@ -2,9 +2,11 @@ import { View } from '../services/MustacheService';
// Populate a View object with some good defaults.
export default function(name: string, title: string): View {
const pathPrefix = name.startsWith('admin/') ? '' : 'index/';
return {
name: name,
path: `index/${name}`,
path: `${pathPrefix}/${name}`,
content: {},
navbar: true,
title: title,

View File

@@ -71,6 +71,10 @@ export function isApiRequest(ctx: AppContext): boolean {
return ctx.path.indexOf('/api/') === 0;
}
export function isAdminRequest(ctx: AppContext): boolean {
return ctx.path.indexOf('/admin/') === 0;
}
export function userIp(ctx: AppContext): string {
if (ctx.headers['x-real-ip']) return ctx.headers['x-real-ip'];
return ctx.ip;

View File

@@ -1,6 +1,7 @@
import { isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
import { ItemAddressingType } from '../services/database/types';
import { RouteType } from './types';
import { expectThrow } from './testing/testUtils';
describe('routeUtils', function() {
@@ -26,6 +27,61 @@ describe('routeUtils', function() {
}
});
it('should find a matching route', async function() {
const testCases: any[] = [
['/admin/organizations', {
route: 1,
basePath: 'admin/organizations',
subPath: {
id: '',
link: '',
addressingType: 1,
raw: '',
schema: 'admin/organizations',
},
}],
['/api/users/123', {
route: 2,
basePath: 'api/users',
subPath: {
id: '123',
link: '',
addressingType: 1,
raw: '123',
schema: 'api/users/:id',
},
}],
['/help', {
route: 3,
basePath: 'help',
subPath: {
id: '',
link: '',
addressingType: 1,
raw: '',
schema: 'help',
},
}],
];
const routes: Record<string, any> = {
'admin/organizations': 1,
'api/users': 2,
'help': 3,
};
for (const testCase of testCases) {
const [path, expected] = testCase;
const actual = findMatchingRoute(path, routes);
expect(actual).toEqual(expected);
}
await expectThrow(async () => findMatchingRoute('help', routes));
await expectThrow(async () => findMatchingRoute('api/users/123', routes));
});
it('should split an item path', async function() {
const testCases: any[] = [
['root:/Documents/MyFile.md:', ['root', 'Documents', 'MyFile.md']],

View File

@@ -223,6 +223,10 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
// - The ID: "SOME_ID"
// - The link: "content"
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
// Enforce that path starts with "/" because if it doesn't, the function
// will return strange but valid results.
if (path.length && path[0] !== '/') throw new Error(`Expected path to start with "/": ${path}`);
const splittedPath = path.split('/');
// Because the path starts with "/", we remove the first element, which is

View File

@@ -24,6 +24,7 @@ import uuidgen from '../uuidgen';
import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
import { parseEnv } from '../../env';
import { URL } from 'url';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@@ -218,7 +219,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
query: req.query,
method: req.method,
redirect: () => {},
URL: { origin: config().baseUrl },
URL: new URL(config().baseUrl), // origin
};
if (options.sessionId) {

View File

@@ -7,6 +7,7 @@ import { Account } from '../models/UserModel';
import { Services } from '../services/types';
import { Routers } from './routeUtils';
import { DbConnection } from '../db';
import { MailerSecurity } from '../env';
export enum Env {
Dev = 'dev',
@@ -74,7 +75,7 @@ export interface MailerConfig {
enabled: boolean;
host: string;
port: number;
secure: boolean;
security: MailerSecurity;
authUser: string;
authPassword: string;
noReplyName: string;
@@ -143,6 +144,7 @@ export interface Config {
tempDir: string;
baseUrl: string;
apiBaseUrl: string;
adminBaseUrl: string;
userContentBaseUrl: string;
joplinAppBaseUrl: string;
signupEnabled: boolean;

View File

@@ -14,6 +14,14 @@ export function setQueryParameters(url: string, query: any): string {
return u.toString();
}
export function stripOffQueryParameters(url: string): string {
const s = url.split('?');
if (s.length <= 1) return url;
s.pop();
return s.join('?');
}
export function resetPasswordUrl(token: string): string {
return `${config().baseUrl}/password/reset${token ? `?token=${token}` : ''}`;
}
@@ -42,14 +50,38 @@ export function homeUrl(): string {
return `${config().baseUrl}/home`;
}
export function itemsUrl(): string {
return `${config().baseUrl}/items`;
}
export function changesUrl(): string {
return `${config().baseUrl}/changes`;
}
export function loginUrl(): string {
return `${config().baseUrl}/login`;
}
export function userDeletionsUrl(): string {
return `${config().baseUrl}/user_deletions`;
export function adminUserDeletionsUrl(): string {
return `${config().adminBaseUrl}/user_deletions`;
}
export function userUrl(userId: Uuid): string {
return `${config().baseUrl}/users/${userId}`;
}
export function adminDashboardUrl(): string {
return `${config().adminBaseUrl}/dashboard`;
}
export function adminUsersUrl() {
return `${config().adminBaseUrl}/users`;
}
export function adminUserUrl(userId: string) {
return `${config().adminBaseUrl}/users/${userId}`;
}
export function adminTasksUrl() {
return `${config().adminBaseUrl}/tasks`;
}

View File

@@ -0,0 +1,4 @@
<h2 class="title">{{global.appName}} admin dashboard</h2>
<div class="block readable-block">
<p class="block">This is the admin dashboard. Please select an option on the left.</p>
</div>

View File

@@ -0,0 +1,170 @@
<h1 class="title">{{pageTitle}}</h1>
<form id="user_form" action="{{{postUrl}}}" method="POST" class="block">
<div class="block">
{{> errorBanner}}
{{{csrfTag}}}
<input type="hidden" name="id" value="{{user.id}}"/>
<input type="hidden" name="is_new" value="{{isNew}}"/>
<div class="field">
<label class="label">Full name</label>
<div class="control">
<input class="input" type="text" name="full_name" value="{{user.full_name}}"/>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email" value="{{user.email}}"/>
</div>
</div>
{{#showAccountTypes}}
<div class="field">
<label class="label">Account type</label>
<div class="select">
<select name="account_type">
{{#accountTypes}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/accountTypes}}
</select>
</div>
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
</div>
{{/showAccountTypes}}
<div class="field">
<label class="label">Max item size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Max total size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Can share notebook</label>
<div class="select">
<select name="can_share_folder">
{{#canShareFolderOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canShareFolderOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Can upload</label>
<div class="select">
<select name="can_upload">
{{#canUploadOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canUploadOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input id="password" class="input" type="password" name="password" autocomplete="new-password"/>
</div>
<p id="password_strength" class="help"></p>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showImpersonateButton}}
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
{{/showImpersonateButton}}
{{#showResetPasswordButton}}
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
{{/showResetPasswordButton}}
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
{{#showScheduleDeletionButton}}
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
{{/showScheduleDeletionButton}}
</div>
</div>
{{#subscription}}
<h1 class="title">Your subscription</h1>
<div class="block">
<div class="control block">
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
</div>
</div>
{{/subscription}}
</form>
{{#hasFlags}}
<div class="content user-flags block">
<h1 class="title">Flags</h1>
<form action="{{{postUrl}}}" method="POST">
{{{csrfTag}}}
{{#userFlagViews}}
<ul>
<li><label class="checkbox"><input type="checkbox" name="user_flag_{{type}}"> {{message}}</label></li>
</ul>
{{/userFlagViews}}
<input type="submit" name="delete_user_flags" class="button is-warning" value="Delete selected flags" />
<p class="help">Note: normally it should not be needed to manually delete a flag because that's automatically handled by the system. So if it's necessary it means there's a bug that should be fixed.</p>
</form>
</div>
{{/hasFlags}}
<script>
$(() => {
document.getElementById("user_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'disable_button') {
const ok = confirm('Disable this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'restore_button') {
const ok = confirm('Restore this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
const ok = confirm('Downgrade to Basic subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
const ok = confirm('Upgrade to Pro subscription?');
if (!ok) event.preventDefault();
}
});
setupPasswordStrengthHandler();
});
</script>

View File

@@ -0,0 +1,60 @@
<div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
</div>
<table class="table">
<thead>
<tr>
<th>Full name</th>
<th>Email</th>
<th>Account</th>
<th>Max Item Size</th>
<th>Total Size</th>
<th>Max Total Size</th>
<th>Can share</th>
</tr>
</thead>
<tbody>
{{#users}}
<tr class="{{rowClassName}}">
<td><a href="{{{url}}}">{{displayName}}</a></td>
<td>{{email}}</td>
<td>{{formattedAccountType}}</td>
<td>{{formattedItemMaxSize}}</td>
<td class="{{totalSizeClass}}">{{formattedTotalSize}} ({{formattedTotalSizePercent}})</td>
<td>{{formattedMaxTotalSize}}</td>
<td>{{formattedCanShareFolder}}</td>
</tr>
{{/users}}
</tbody>
</table>
<div class="block">
<a class="button is-primary" href="{{{global.baseUrl}}}/admin/users/new">Add user</a>
<a class="button is-link toggle-disabled-button hide-disabled" href="#">Hide disabled</a>
</div>
<script>
$(() => {
function toggleDisabled() {
if ($('.hide-disabled').length) {
$('.hide-disabled').addClass('show-disabled');
$('.hide-disabled').removeClass('hide-disabled');
$('.show-disabled').text('Show disabled');
$('table tr.is-disabled').hide();
} else {
$('.show-disabled').addClass('hide-disabled');
$('.show-disabled').removeClass('show-disabled');
$('.hide-disabled').text('Hide disabled');
$('table tr.is-disabled').show();
}
}
toggleDisabled();
$('.toggle-disabled-button').click(() => {
toggleDisabled();
});
});
</script>

View File

@@ -20,6 +20,10 @@ To switch between yearly and monthly payments, or to change from a Basic to Pro
Note that if you downgrade from Pro to Basic, new limitations will apply so for example you may have to delete some notes so that your account is below the required limit.
## Can my subscription be refunded?
We offer a 14 days trial when the subscription starts so that you can evaluate the service and potentially change your mind - if you cancel during that period you will not be charged. After 14 days your card will be charged and it will not be possible to issue a refund.
## 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".

View File

@@ -20,58 +20,6 @@
</div>
</div>
{{#global.owner.is_admin}}
{{#showAccountTypes}}
<div class="field">
<label class="label">Account type</label>
<div class="select">
<select name="account_type">
{{#accountTypes}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/accountTypes}}
</select>
</div>
<p class="help">If the below properties are left to their default (empty) values, the account-specific properties will apply.</p>
</div>
{{/showAccountTypes}}
<div class="field">
<label class="label">Max item size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_item_size" value="{{user.max_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Max total size</label>
<div class="control">
<input class="input" type="number" placeholder="Default" name="max_total_item_size" value="{{user.max_total_item_size}}"/>
</div>
</div>
<div class="field">
<label class="label">Can share notebook</label>
<div class="select">
<select name="can_share_folder">
{{#canShareFolderOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canShareFolderOptions}}
</select>
</div>
</div>
<div class="field">
<label class="label">Can upload</label>
<div class="select">
<select name="can_upload">
{{#canUploadOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canUploadOptions}}
</select>
</div>
</div>
{{/global.owner.is_admin}}
<div class="field">
<label class="label">Password</label>
<div class="control">
@@ -85,28 +33,10 @@
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
{{#global.owner.is_admin}}
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
{{/global.owner.is_admin}}
</div>
</div>
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showImpersonateButton}}
<input type="submit" name="impersonate_button" class="button is-link" value="Impersonate user" />
{{/showImpersonateButton}}
{{#showResetPasswordButton}}
<input type="submit" name="send_account_confirmation_email" class="button is-link" value="Send account confirmation email" />
{{/showResetPasswordButton}}
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
{{#showScheduleDeletionButton}}
<input type="submit" name="schedule_deletion_button" class="button is-danger" value="Schedule for deletion" />
{{/showScheduleDeletionButton}}
</div>
</div>
@@ -114,33 +44,17 @@
<h1 class="title">Your subscription</h1>
<div class="block">
{{#global.owner.is_admin}}
{{#showUpdateSubscriptionPro}}
<div class="control block">
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
</div>
{{/global.owner.is_admin}}
{{^global.owner.is_admin}}
{{#showUpdateSubscriptionPro}}
<div class="control block">
<p><a href="{{{global.baseUrl}}}/upgrade" class="button is-warning block">Upgrade to Pro</a></p>
<p class="help">Click for more info about the Pro plan and to upgrade your account.</p>
</div>
{{/showUpdateSubscriptionPro}}
{{#showManageSubscription}}
<div class="control block">
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
</div>
{{/showManageSubscription}}
{{/global.owner.is_admin}}
{{/showUpdateSubscriptionPro}}
<div class="control block">
<p><a class="button is-link" target="_blank" href="{{stripePortalUrl}}">Manage subscription</a></p>
<p class="help">Click to update your payment details, switch to a different billing cycle or plan, or to cancel your subscription.</p>
</div>
</div>
{{/subscription}}
@@ -164,28 +78,6 @@
<script>
$(() => {
document.getElementById("user_form").addEventListener('submit', function(event) {
if (event.submitter.getAttribute('name') === 'disable_button') {
const ok = confirm('Disable this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'restore_button') {
const ok = confirm('Restore this account?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
const ok = confirm('Downgrade to Basic subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
const ok = confirm('Upgrade to Pro subscription?');
if (!ok) event.preventDefault();
}
});
setupPasswordStrengthHandler();
});
</script>

View File

@@ -24,7 +24,14 @@
<main class="main">
<div class="container">
{{> notifications}}
{{{contentHtml}}}
{{#global.isAdminPage}}
{{> adminLayout}}
{{/global.isAdminPage}}
{{^global.isAdminPage}}
{{{contentHtml}}}
{{/global.isAdminPage}}
</div>
</main>
{{> footer}}

View File

@@ -0,0 +1,20 @@
<div style="display: flex; flex-direction: row;">
<div style="display: flex; margin-right: 3rem; background-color: #f5f5f5; padding: 1.5rem;">
<aside class="menu">
{{#global.adminMenu}}
<p class="menu-label">{{title}}</p>
<ul class="menu-list">
{{#children}}
<li><a href="{{{url}}}" class="{{#selected}}is-active{{/selected}}">{{title}}</a></li>
{{/children}}
</ul>
{{/global.adminMenu}}
</aside>
</div>
<div style="display: flex; flex: 1;">
<div>
{{{contentHtml}}}
</div>
</div>
</div>

View File

@@ -10,27 +10,17 @@
{{#global.owner}}
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/home">{{s.home}}</a>
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/users">{{s.users}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">{{s.items}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">{{s.log}}</a>
{{/global.owner.is_admin}}
{{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/tasks">{{s.tasks}}</a>
{{/global.owner.is_admin}}
{{#global.navbarMenu}}
<a class="navbar-item {{#selected}}is-active{{/selected}}" href="{{{url}}}">{{#icon}}<i class="{{.}}"></i>&nbsp;&nbsp;{{/icon}}{{title}}</a>
{{/global.navbarMenu}}
</div>
<div class="navbar-end">
{{#global.isJoplinCloud}}
<a class="navbar-item" href="{{{global.baseUrl}}}/help">{{s.help}}</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/help">{{global.s.help}}</a>
{{/global.isJoplinCloud}}
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-dark">{{s.logout}}</button>
<button class="button is-dark">{{global.s.logout}}</button>
</form>
</div>
<div class="navbar-item">

View File

@@ -347,7 +347,7 @@ function flagImageUrl(locale) {
if (locale === 'sv') return `${baseUrl}/country-4x3/se.png`;
if (locale === 'nb_NO') return `${baseUrl}/country-4x3/no.png`;
if (locale === 'ro') return `${baseUrl}/country-4x3/ro.png`;
if (locale === 'vi') return `${baseUrl}/country-4x3/vi.png`;
if (locale === 'vi') return `${baseUrl}/country-4x3/vn.png`;
if (locale === 'fa') return `${baseUrl}/country-4x3/ir.png`;
if (locale === 'eo') return `${baseUrl}/esperanto.png`;
return `${baseUrl}/country-4x3/${countryCodeOnly(locale).toLowerCase()}.png`;

View File

@@ -19,6 +19,7 @@ async function main() {
const argv = require('yargs').argv;
if (!argv.tagName) throw new Error('--tag-name not provided');
const dryRun = !!argv.dryRun;
const pushImages = !!argv.pushImages;
const tagName = argv.tagName;
const isPreRelease = getIsPreRelease(tagName);
@@ -47,7 +48,13 @@ async function main() {
console.info('isPreRelease:', isPreRelease);
console.info('Docker tags:', dockerTags.join(', '));
await execCommand2(`docker build --progress=plain -t "joplin/server:${imageVersion}" ${buildArgs} -f Dockerfile.server .`);
const dockerCommand = `docker build --progress=plain -t "joplin/server:${imageVersion}" ${buildArgs} -f Dockerfile.server .`;
if (dryRun) {
console.info(dockerCommand);
return;
}
await execCommand2(dockerCommand);
for (const tag of dockerTags) {
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:${tag}"`);

View File

@@ -15,6 +15,8 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.2\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."
@@ -33,8 +35,8 @@ msgid ""
"- Storage: to allow attaching files to notes and to enable filesystem "
"synchronisation."
msgstr ""
"- Lagring: för att tillåta att du bifoga filer till anteckningar och för att "
"aktivera filsystemsynkronisering."
"- Lagring: för att tillåta att du bifogar filer till anteckningar och för "
"att aktivera filsystemsynkronisering."
#: packages/lib/services/KeymapService.ts:308
#: packages/lib/services/KeymapService.ts:314
@@ -303,7 +305,7 @@ msgstr "Utseende"
#: packages/lib/models/Setting.ts:2146
msgid "Application"
msgstr "Avslutar programmet"
msgstr "Program"
#: packages/app-desktop/gui/ConfigScreen/ButtonBar.tsx:33
msgid "Apply"
@@ -558,7 +560,7 @@ msgstr ""
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.ts:7
msgid "Change application layout"
msgstr "Ändra applikationslayout"
msgstr "Ändra programmets layout"
#: packages/lib/services/spellChecker/SpellCheckerService.ts:189
msgid "Change language"
@@ -844,6 +846,8 @@ msgid ""
"Could not verify the share status of this notebook - aborting. Please try "
"again when you are connected to the internet."
msgstr ""
"Det gick inte att verifiera delningsstatusen för den här anteckningsboken - "
"avbryter. Vänligen försök igen när du är ansluten till internet."
#: packages/app-mobile/components/note-list.js:101
msgid "Create a notebook"
@@ -997,7 +1001,7 @@ msgstr "Ta bort rad"
#: packages/lib/models/Setting.ts:1186
msgid "Delete local data and re-download from sync target"
msgstr "Ta bort lokal data och hämta igen från synkroniseringsmålet"
msgstr "Ta bort lokala data och hämta igen från synkroniseringsmålet"
#: packages/lib/models/Note.ts:760
msgid "Delete note \"%s\"?"
@@ -1536,7 +1540,7 @@ msgstr "Exportera felsökningsrapport"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:528
msgid "Export profile"
msgstr "Exportera profilen"
msgstr "Exportera profil"
#: packages/app-mobile/components/screens/ConfigScreen.tsx:528
msgid "Exporting profile..."
@@ -1572,7 +1576,7 @@ msgid ""
"Fail-safe: Do not wipe out local data when sync target is empty (often the "
"result of a misconfiguration or bug)"
msgstr ""
"Felsäkert: Rensa inte lokal data när synkroniseringsmålet är tomt (beror "
"Felsäkert: Rensa inte lokala data när synkroniseringsmålet är tomt (beror "
"oftast på felkonfigurering eller en bugg)"
#: packages/app-cli/app/main.js:95
@@ -1657,7 +1661,7 @@ msgstr ""
#: packages/lib/models/Setting.ts:568
msgid "Force path style"
msgstr ""
msgstr "Tvinga sökvägsstil"
#: packages/lib/commands/historyForward.ts:6
msgid "Forward"
@@ -1726,7 +1730,6 @@ msgid "Heading"
msgstr "Rubrik"
#: packages/server/src/services/MustacheService.ts:201
#, fuzzy
msgid "Help"
msgstr "Hjälp"
@@ -1748,7 +1751,7 @@ msgstr "Markera"
#: packages/server/src/services/MustacheService.ts:196
msgid "Home"
msgstr ""
msgstr "Hem"
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:78
msgid "Horizontal Rule"
@@ -1885,7 +1888,7 @@ msgstr ""
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:142
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:129
msgid "In order to use the web clipper, you need to do the following:"
msgstr "För att kunna använda web Clipper måste du göra följande:"
msgstr "För att kunna använda Web Clipper måste du göra följande:"
#: packages/lib/Synchronizer.ts:305
msgid "In progress"
@@ -1961,9 +1964,8 @@ msgid "Invalid option value: \"%s\". Possible values are: %s."
msgstr "Ogiltigt inställningsvärde: \"%s\". Möjliga värden är: %s."
#: packages/app-cli/app/command-e2ee.ts:46
#, fuzzy
msgid "Invalid password"
msgstr "Ogiltigt svar: %s"
msgstr "Ogiltigt lösenord"
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:38
msgid "Italic"
@@ -2192,7 +2194,7 @@ msgstr "Logga in med OneDrive"
#: packages/server/src/services/MustacheService.ts:202
msgid "Logout"
msgstr ""
msgstr "Logga ut"
#: packages/app-desktop/gui/MenuBar.tsx:705
#: packages/app-mobile/components/screens/ConfigScreen.tsx:583
@@ -2521,9 +2523,8 @@ msgid "Note&book"
msgstr "Antecknings&bok"
#: packages/lib/models/Setting.ts:2143
#, fuzzy
msgid "Notebook"
msgstr "Anteckningsböcker"
msgstr "Anteckningsbok"
#: packages/lib/models/Setting.ts:1378
msgid "Notebook list growth factor"
@@ -2549,9 +2550,8 @@ msgstr ""
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts:8
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts:9
#, fuzzy
msgid "Notes"
msgstr "Anteckning"
msgstr "Anteckningar"
#: packages/lib/models/Setting.ts:2159
msgid "Notes and settings are stored in: %s"
@@ -2894,7 +2894,7 @@ msgstr "Omkryptering"
#: packages/lib/models/Setting.ts:1175
msgid "Re-upload local data to sync target"
msgstr "Ladda upp lokal data igen för synkroniseringsmålet"
msgstr "Ladda upp lokala data igen till synkroniseringsmålet"
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:439
msgid "Read more about it"
@@ -3034,31 +3034,27 @@ msgstr "Kör kommandona i textfilen. Det ska finnas ett kommando per rad."
#: packages/lib/SyncTargetAmazonS3.js:28
msgid "S3"
msgstr ""
msgstr "S3"
#: packages/lib/models/Setting.ts:546
#, fuzzy
msgid "S3 access key"
msgstr "AWS-nyckel"
msgstr "S3 åtkomstnyckel"
#: packages/lib/models/Setting.ts:506
#, fuzzy
msgid "S3 bucket"
msgstr "AWS S3"
msgstr "S3 bucket"
#: packages/lib/models/Setting.ts:535
msgid "S3 region"
msgstr ""
#: packages/lib/models/Setting.ts:557
#, fuzzy
msgid "S3 secret key"
msgstr "AWS-hemlighet"
msgstr "S3 hemlig nyckel"
#: packages/lib/models/Setting.ts:521
#, fuzzy
msgid "S3 URL"
msgstr "AWS S3 URL"
msgstr "S3 URL"
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:579
msgid ""
@@ -3224,9 +3220,8 @@ msgid "Show note counts"
msgstr "Visa anteckningsantal"
#: packages/lib/models/Setting.ts:866
#, fuzzy
msgid "Show sort order buttons"
msgstr "Visa anteckningsantal"
msgstr "Visa sorteringsordningsknappar"
#: packages/lib/models/Setting.ts:1042
msgid "Show tray icon"
@@ -3557,7 +3552,7 @@ msgstr "Ta ett foto"
#: packages/server/src/services/MustacheService.ts:200
msgid "Tasks"
msgstr ""
msgstr "Uppgifter"
#: packages/lib/models/Setting.ts:1228
msgid "Text editor command"
@@ -3589,7 +3584,7 @@ msgstr "Programmet har godkänts."
#: packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx:619
msgid "The application must be restarted for these changes to take effect."
msgstr ""
"Applikationen måste startas om för att dessa ändringar ska träda i kraft."
"Programmet måste startas om för att dessa ändringar ska träda i kraft."
#: packages/app-desktop/gui/NoteEditor/NoteEditor.tsx:511
msgid ""
@@ -3616,13 +3611,13 @@ msgid ""
"is recommended that you apply it to your data."
msgstr ""
"Standard krypteringsmetod har ändrats till en säkrare och det är "
"rekommenderat att du tillämpar den på din data."
"rekommenderat att du tillämpar den på dina data."
#: packages/app-desktop/gui/MainScreen/MainScreen.tsx:603
msgid ""
"The default encryption method has been changed, you should re-encrypt your "
"data."
msgstr "Standardkrypteringsmetoden har ändrats, du bör omkryptera din data."
msgstr "Standardkrypteringsmetoden har ändrats, du bör omkryptera dina data."
#: packages/lib/models/Setting.ts:1228
msgid ""
@@ -3761,7 +3756,7 @@ msgstr "Web Clipper behöver din behörighet för att få åtkomst till dina dat
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:83
msgid "The web clipper service is enabled and set to auto-start."
msgstr "Web clipper-tjänsten är aktiverad och inställd för automatisk start."
msgstr "Web Clipper-tjänsten är aktiverad och inställd för automatisk start."
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:109
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:107
@@ -3787,7 +3782,7 @@ msgstr ""
#: packages/lib/services/interop/InteropService_Exporter_Jex.ts:35
msgid "There is no data to export."
msgstr "Det finns ingen data att exportera."
msgstr "Det finns inga data att exportera."
#: packages/lib/models/Resource.ts:412
msgid ""
@@ -4035,9 +4030,8 @@ msgid "Toggle note list"
msgstr "Växla anteckningslista"
#: packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts:7
#, fuzzy
msgid "Toggle own sort order"
msgstr "Växla säkert läge"
msgstr "Växla egen sorteringsordning"
#: packages/app-desktop/commands/toggleSafeMode.ts:8
msgid "Toggle safe mode"
@@ -4048,9 +4042,8 @@ msgid "Toggle sidebar"
msgstr "Växla sidofältet"
#: packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts:7
#, fuzzy
msgid "Toggle sort order field"
msgstr "Växla säkert läge"
msgstr "Växla sorteringsordningsfält"
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:40
#: packages/app-desktop/gui/ClipperConfigScreen.tsx:41
@@ -4257,7 +4250,7 @@ msgstr ""
#: packages/server/src/services/MustacheService.ts:197
msgid "Users"
msgstr ""
msgstr "Användare"
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:182
msgid "Valid"
@@ -4394,7 +4387,7 @@ msgid ""
"You may use the tool below to re-encrypt your data, for example if you know "
"that some of your notes are encrypted with an obsolete encryption method."
msgstr ""
"Du kan använda verktyget nedan för att omkryptera din data, exempelvis om du "
"Du kan använda verktyget nedan för att omkryptera dina data, exempelvis om du "
"vill veta om vissa av dina anteckningar är krypterade med en gammal "
"krypteringsmetod."
@@ -4404,18 +4397,19 @@ msgstr "Ditt val: "
#: packages/lib/components/EncryptionConfigScreen/utils.ts:71
msgid "Your data is going to be re-encrypted and synced again."
msgstr "Din data kommer att omkrypteras och synkroniseras igen."
msgstr "Dina data kommer att omkrypteras och synkroniseras igen."
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:271
msgid "Your master password is needed to decrypt some of your data."
msgstr "Ditt huvudlösenord behövs för att dekryptera några av dina data."
#: packages/app-cli/app/command-sync.ts:242
#, fuzzy
msgid ""
"Your password is needed to decrypt some of your data. Type `:e2ee decrypt` "
"to set it."
msgstr "Ditt huvudlösenord behövs för att dekryptera några av dina data."
msgstr ""
"Ditt lösenord behövs för att dekryptera en del av dina data. Skriv `:e2ee "
"decrypt` för att ställa in det."
#: packages/app-mobile/components/CameraView.tsx:189
msgid "Your permission to use your camera is required."

View File

@@ -147,7 +147,7 @@ export function execCommand(command: string, options: any = null): Promise<strin
});
}
export function resolveRelativePathWithinDir(baseDir: string, ...relativePath: string[]) {
export function resolveRelativePathWithinDir(baseDir: string, ...relativePath: string[]): string {
const path = require('path');
const resolvedBaseDir = path.resolve(baseDir);
const resolvedPath = path.resolve(baseDir, ...relativePath);

View File

@@ -167,6 +167,10 @@ function makeHomePageMd() {
// while MarkdownIt doesn't and will in fact display the \. So we remove it here.
md = md.replace(/\\\| bash/g, '| bash');
// We strip-off the donate links because they are added back (with proper
// classes and CSS).
md = md.replace(donateLinksRegex_, '');
return md;
}
@@ -212,14 +216,19 @@ async function main() {
const assetUrls = await getAssetUrls();
const readmeMd = makeHomePageMd();
// await updateDownloadPage(readmeMd);
const donateLinksMd = await getDonateLinks();
// =============================================================
// HELP PAGE
// =============================================================
renderPageToHtml(readmeMd, `${docDir}/help/index.html`, { sourceMarkdownFile: 'README.md', partials, sponsors, assetUrls });
renderPageToHtml(readmeMd, `${docDir}/help/index.html`, {
sourceMarkdownFile: 'README.md',
donateLinksMd,
partials,
sponsors,
assetUrls,
});
// =============================================================
// FRONT PAGE
@@ -279,7 +288,6 @@ async function main() {
const mdFiles = glob.sync(`${readmeDir}/**/*.md`).map((f: string) => f.substr(rootDir.length + 1));
const sources = [];
const donateLinksMd = await getDonateLinks();
const makeTargetFilePath = (input: string): string => {
if (isNewsFile(input)) {

View File

@@ -1,6 +1,6 @@
# GSoC 2022 Ideas
2022 is Joplin second round at Google Summer of Code. Detailed information on how to get involved and apply are given in the [general Summer of Code introduction](https://joplinapp.org/gsoc2022/index/)
2022 is Joplin third round at Google Summer of Code. Detailed information on how to get involved and apply are given in the [general Summer of Code introduction](https://joplinapp.org/gsoc2022/index/)
**These are all proposals! We are open to new ideas you might have!!** Do you have an awesome idea you want to work on with Joplin but that is not among the ideas below? That's cool. We love that! But please do us a favour: Get in touch with a mentor early on and make sure your project is realistic and within the scope of Joplin. Just make sure your idea is within this year's theme:
@@ -81,6 +81,75 @@ Difficulty level: High
Skills Required: Typescript, Javascript, CSS, HTML, Markdown rendering. You will also need to learn about TinyMCE if you're not already familiar with it.
## 7. Improve PDF export
Joplin uses Chrome's built-in print to PDF function which is very limited. This can be improved by using a 3rd party library to convert notes to PDF. Applies to desktop and CLI versions.
Potential benefits:
* Export multiple notes as a single PDF
* Embedding attachments (see https://github.com/laurent22/joplin/issues/5943)
* Delay export until the note is fully rendered (https://discourse.joplinapp.org/t/ability-to-delay-pdf-export-to-allow-plugins-to-render/22159)
Difficulty level: Medium
Skills Required: Typescript, Javascript.
## 8. Replace built-in PDF renderer with a library
Just like with export, Joplin relies on the built-in PDF renderer to show PDF attachments. Replacing it with a 3rd-party library has a number of advantages:
* Joplin can preserve PDF viewer state when a note is re-rendered. For instance currently after opening and closing settings, PDF are reset to the 1st page.
* It may be possible to link to a specific page or even a location within a PDF document.
* Annotate PDF documents from Joplin
Difficulty level: Medium
Skills Required: Typescript, Javascript.
## 9. Rebuild file system sync on Android
A recent update broke file system synchronization on Android, as applications are now required to use a new API to access storage. Currently there are no libraries that would proxy this API for React Native. If we want to get file system sync working again it has to be written from scratch.
Difficulty level: High
Skills Required: Android, Java/Kotlin, Typescript.
## 10. Tablet layout
On wide screens devices like tables Joplin could use a different layout, e.g. with note list always showing, or have both editor and viewer visible at the same time.
Difficulty Level: Medium
Skills Required: React, Typescript, CSS.
## 11. Improve plugin search and discoverability
As there are more and more plugins it would be good to improve how they are discovered, and to improve search - in particular improve search relevance. We are open to hear ideas about this, but a few things that could be done, for example are:
- Improve the [page that lists all the plugin](https://github.com/joplin/plugins#readme) by adding a download count (based on stats.json) and make the list sortable by download count.
- In the app, use the info from stats.json to order the plugin - those with more downloads going on top for example
- Create a dynamically generated page (using GitHub Actions) under joplinapp.org that shows some recommended plugins, trending plugins, etc. similar to [Add-ons for Firefox](https://addons.mozilla.org/en-GB/firefox/)
Those are just ideas and we're open to hearing more from you.
Difficulty Level: Medium
Skills Required: Typescript, CSS, GitHub Actions.
## 12. Email plugin
Create a plugin to fetch mail via IMAP and convert messages to notes (including attachments). The plugin should be able to filter what messages it donwloads, e.g. based on the folder.
Additional features to consider:
- support more than one account
- convert HTML to Markdown
- delete/move received emails
Difficulty Level: Medium
Skills Required: TypeScript, JavaScript.
# More info
- Make sure you read the [Joplin Google Summer of Code Introduction](https://joplinapp.org/gsoc2022/index/)

View File

@@ -19,7 +19,7 @@ Length | 6 chars (Hexa string)
Encryption method | 2 chars (Hexa string)
Master key ID | 32 chars (Hexa string)
See `lib/services/EncryptionService.js` for the list of available encryption methods.
See `lib/services/e2ee/EncryptionService.ts` for the list of available encryption methods.
### Data chunk
@@ -32,7 +32,7 @@ Data | ("Length" bytes) (ASCII)
## Master Keys
The master keys are used to encrypt and decrypt data. They can be generated from the Encryption Service and are saved to the database. They are themselves encrypted via a user password using a [strong encryption method](https://github.com/laurent22/joplin/blob/f21199a7f38b43d1f350ee81f84d4f335cb285b3/packages/lib/services/EncryptionService.js#L374).
The master keys are used to encrypt and decrypt data. They can be generated from the Encryption Service and are saved to the database. They are themselves encrypted via a user password using a [strong encryption method](https://github.com/laurent22/joplin/blob/b5b02d8d7bce2c07c89fef50103e1399d792b75e/packages/lib/services/e2ee/EncryptionService.ts#L373).
These encrypted master keys are transmitted with the sync data so that they can be available to each client. Each client will need to supply the user password to decrypt each key.