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

Compare commits

...

33 Commits

Author SHA1 Message Date
Laurent Cozic
dac3685a56 fix docker-compose 2021-01-18 08:15:14 +00:00
Laurent Cozic
bdf0e50b56 tags 2021-01-18 01:39:33 +00:00
Laurent Cozic
0b14d927ce Server release v1.7.1 2021-01-18 01:38:09 +00:00
Laurent Cozic
767041dddb release script 2021-01-18 01:33:35 +00:00
Laurent Cozic
df65b8e6de Clean up config 2021-01-17 19:39:27 +00:00
Laurent Cozic
58c2bb5916 Detect env 2021-01-17 18:41:17 +00:00
Laurent Cozic
dce2f2955f Merge branch 'docker_server_update' of github.com:laurent22/joplin into docker_server_update 2021-01-17 16:57:17 +00:00
Florian Jensen
fdbd790ce1 Server: fix Docker build (#4381) 2021-01-17 16:51:41 +00:00
Laurent Cozic
d979866f2f Update doc 2021-01-17 12:45:35 +00:00
Laurent Cozic
d3a53abe32 Various tweaks 2021-01-17 12:04:55 +00:00
Laurent Cozic
31ef1d675c update 2021-01-15 23:34:40 +00:00
Laurent Cozic
58b5f4830c Merge branch 'dev' into docker_server_update 2021-01-15 22:30:50 +00:00
Laurent Cozic
eb3493f648 Server: Fixed tests and clean up 2021-01-15 22:02:36 +00:00
Laurent Cozic
7fd4c28a5b Plugin Generator release v1.7.3 2021-01-15 17:04:18 +00:00
Laurent Cozic
d1b55aeceb Generator: Fixes #4360: Scripts were no longer being compiled 2021-01-15 17:03:38 +00:00
Laurent Cozic
92095c5f34 update 2021-01-15 16:51:02 +00:00
Laurent Cozic
413ec1a933 Server: Refactored to use Router class 2021-01-14 22:36:46 +00:00
Laurent Cozic
7ad29577f9 Server: Improved how routes can be defined 2021-01-14 18:27:59 +00:00
Laurent Cozic
7652a5a0a0 Server: Added tests for logout and fixed transaction deadlock 2021-01-14 17:18:27 +00:00
Laurent Cozic
105189fc57 Plugin Generator release v1.7.2 2021-01-14 15:48:10 +00:00
Laurent Cozic
e559999aa4 Generator: Fixed crash when no external script needs to be compiled 2021-01-14 15:47:34 +00:00
Laurent Cozic
4a230d7cd5 Server: Removed all controller
These controllers were mostly here to allow testing the business logic.
However now that the routes are tested directly they are no longer
necessary. And testing the routes significantly increase the test
coverage.
2021-01-14 14:28:20 +00:00
Laurent Cozic
9b2e5e2959 Server: Removed the need for session controller 2021-01-14 13:29:03 +00:00
Laurent Cozic
3c5ac1ecc5 Server: Removed the need for file controller 2021-01-14 13:07:38 +00:00
Laurent Cozic
03dc1bbfe1 Server: Removed the need for profile controller 2021-01-14 12:50:45 +00:00
Laurent Cozic
80580ba54d Server: Clean up test units 2021-01-14 11:55:27 +00:00
Laurent Cozic
6a80b2ae9e Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-01-13 23:26:02 +00:00
Laurent Cozic
f14ea46f0b Server: Moved controller tests to route and model 2021-01-13 23:20:45 +00:00
Laurent Cozic
247bd9bfd9 Server: Moved session tests to route 2021-01-13 22:06:47 +00:00
Laurent Cozic
fc58db5d1a Server: Removed controller dependency from route 2021-01-13 21:50:43 +00:00
Robin Opletal
466cd836d7 Add support for the sway desktop environment (#4357)
Many sway users set $XDG_CURRENT_DESKTOP to sway. This could be detected.
2021-01-13 20:50:32 +00:00
Laurent Cozic
66a09e5068 Server: Moved file API tests to route 2021-01-13 18:11:35 +00:00
jibedoubleve
b53e475f99 Update French translation + fix a typo (#4349) 2021-01-13 12:39:59 +00:00
99 changed files with 2327 additions and 2301 deletions

View File

@@ -1,9 +1,26 @@
# Example of local config, for development:
# =============================================================================
# PRODUCTION CONFIG EXAMPLE
# -----------------------------------------------------------------------------
# By default it will use SQLite, but that's mostly to test and evaluate the
# server. So you'll want to specify db connection settings to use Postgres.
# =============================================================================
#
# JOPLIN_BASE_URL=http://localhost:22300
# JOPLIN_PORT=22300
# APP_BASE_URL=https://example.com/joplin
# APP_PORT=22300
#
# DB_CLIENT=pg
# POSTGRES_PASSWORD=joplin
# POSTGRES_DATABASE=joplin
# POSTGRES_USER=joplin
# POSTGRES_PORT=5432
# POSTGRES_HOST=localhost
# Example of config for production:
# =============================================================================
# DEV CONFIG EXAMPLE
# -----------------------------------------------------------------------------
# Example of local config, for development. In dev mode, you would usually use
# SQLite so database settings are not needed.
# =============================================================================
#
# JOPLIN_BASE_URL=https://example.com/joplin
# JOPLIN_PORT=22300
# APP_BASE_URL=http://localhost:22300
# APP_PORT=22300

View File

@@ -6,6 +6,7 @@ _releases/
**/node_modules/
Assets/
docs/
packages/plugins/**/dist
packages/server/dist/
highlight.pack.js
Modules/TinyMCE/IconPack/postinstall.js
@@ -1478,51 +1479,6 @@ packages/server/src/config-tests.js.map
packages/server/src/config.d.ts
packages/server/src/config.js
packages/server/src/config.js.map
packages/server/src/controllers/BaseController.d.ts
packages/server/src/controllers/BaseController.js
packages/server/src/controllers/BaseController.js.map
packages/server/src/controllers/api/FileController.d.ts
packages/server/src/controllers/api/FileController.js
packages/server/src/controllers/api/FileController.js.map
packages/server/src/controllers/api/FileController.test.d.ts
packages/server/src/controllers/api/FileController.test.js
packages/server/src/controllers/api/FileController.test.js.map
packages/server/src/controllers/api/OAuthController.d.ts
packages/server/src/controllers/api/OAuthController.js
packages/server/src/controllers/api/OAuthController.js.map
packages/server/src/controllers/api/SessionController.d.ts
packages/server/src/controllers/api/SessionController.js
packages/server/src/controllers/api/SessionController.js.map
packages/server/src/controllers/api/SessionController.test.d.ts
packages/server/src/controllers/api/SessionController.test.js
packages/server/src/controllers/api/SessionController.test.js.map
packages/server/src/controllers/api/UserController.d.ts
packages/server/src/controllers/api/UserController.js
packages/server/src/controllers/api/UserController.js.map
packages/server/src/controllers/api/UserController.test.d.ts
packages/server/src/controllers/api/UserController.test.js
packages/server/src/controllers/api/UserController.test.js.map
packages/server/src/controllers/factory.d.ts
packages/server/src/controllers/factory.js
packages/server/src/controllers/factory.js.map
packages/server/src/controllers/index/FileController.d.ts
packages/server/src/controllers/index/FileController.js
packages/server/src/controllers/index/FileController.js.map
packages/server/src/controllers/index/HomeController.d.ts
packages/server/src/controllers/index/HomeController.js
packages/server/src/controllers/index/HomeController.js.map
packages/server/src/controllers/index/LoginController.d.ts
packages/server/src/controllers/index/LoginController.js
packages/server/src/controllers/index/LoginController.js.map
packages/server/src/controllers/index/NotificationController.d.ts
packages/server/src/controllers/index/NotificationController.js
packages/server/src/controllers/index/NotificationController.js.map
packages/server/src/controllers/index/ProfileController.d.ts
packages/server/src/controllers/index/ProfileController.js
packages/server/src/controllers/index/ProfileController.js.map
packages/server/src/controllers/index/UserController.d.ts
packages/server/src/controllers/index/UserController.js
packages/server/src/controllers/index/UserController.js.map
packages/server/src/db.d.ts
packages/server/src/db.js
packages/server/src/db.js.map
@@ -1580,6 +1536,9 @@ packages/server/src/models/SessionModel.js.map
packages/server/src/models/UserModel.d.ts
packages/server/src/models/UserModel.js
packages/server/src/models/UserModel.js.map
packages/server/src/models/UserModel.test.d.ts
packages/server/src/models/UserModel.test.js
packages/server/src/models/UserModel.test.js.map
packages/server/src/models/factory.d.ts
packages/server/src/models/factory.js
packages/server/src/models/factory.js.map
@@ -1592,9 +1551,9 @@ packages/server/src/models/utils/pagination.test.js.map
packages/server/src/routes/api/files.d.ts
packages/server/src/routes/api/files.js
packages/server/src/routes/api/files.js.map
packages/server/src/routes/api/index.d.ts
packages/server/src/routes/api/index.js
packages/server/src/routes/api/index.js.map
packages/server/src/routes/api/files.test.d.ts
packages/server/src/routes/api/files.test.js
packages/server/src/routes/api/files.test.js.map
packages/server/src/routes/api/ping.d.ts
packages/server/src/routes/api/ping.js
packages/server/src/routes/api/ping.js.map
@@ -1604,6 +1563,9 @@ packages/server/src/routes/api/ping.test.js.map
packages/server/src/routes/api/sessions.d.ts
packages/server/src/routes/api/sessions.js
packages/server/src/routes/api/sessions.js.map
packages/server/src/routes/api/sessions.test.d.ts
packages/server/src/routes/api/sessions.test.js
packages/server/src/routes/api/sessions.test.js.map
packages/server/src/routes/default.d.ts
packages/server/src/routes/default.js
packages/server/src/routes/default.js.map
@@ -1613,24 +1575,33 @@ packages/server/src/routes/index/files.js.map
packages/server/src/routes/index/home.d.ts
packages/server/src/routes/index/home.js
packages/server/src/routes/index/home.js.map
packages/server/src/routes/index/home.test.d.ts
packages/server/src/routes/index/home.test.js
packages/server/src/routes/index/home.test.js.map
packages/server/src/routes/index/login.d.ts
packages/server/src/routes/index/login.js
packages/server/src/routes/index/login.js.map
packages/server/src/routes/index/login.test.d.ts
packages/server/src/routes/index/login.test.js
packages/server/src/routes/index/login.test.js.map
packages/server/src/routes/index/logout.d.ts
packages/server/src/routes/index/logout.js
packages/server/src/routes/index/logout.js.map
packages/server/src/routes/index/logout.test.d.ts
packages/server/src/routes/index/logout.test.js
packages/server/src/routes/index/logout.test.js.map
packages/server/src/routes/index/notifications.d.ts
packages/server/src/routes/index/notifications.js
packages/server/src/routes/index/notifications.js.map
packages/server/src/routes/index/profile.d.ts
packages/server/src/routes/index/profile.js
packages/server/src/routes/index/profile.js.map
packages/server/src/routes/index/user.d.ts
packages/server/src/routes/index/user.js
packages/server/src/routes/index/user.js.map
packages/server/src/routes/index/notifications.test.d.ts
packages/server/src/routes/index/notifications.test.js
packages/server/src/routes/index/notifications.test.js.map
packages/server/src/routes/index/users.d.ts
packages/server/src/routes/index/users.js
packages/server/src/routes/index/users.js.map
packages/server/src/routes/index/users.test.d.ts
packages/server/src/routes/index/users.test.js
packages/server/src/routes/index/users.test.js.map
packages/server/src/routes/oauth2/authorize.d.ts
packages/server/src/routes/oauth2/authorize.js
packages/server/src/routes/oauth2/authorize.js.map
@@ -1649,6 +1620,9 @@ packages/server/src/tools/dbTools.js.map
packages/server/src/tools/generate-types.d.ts
packages/server/src/tools/generate-types.js
packages/server/src/tools/generate-types.js.map
packages/server/src/utils/Router.d.ts
packages/server/src/utils/Router.js
packages/server/src/utils/Router.js.map
packages/server/src/utils/TransactionHandler.d.ts
packages/server/src/utils/TransactionHandler.js
packages/server/src/utils/TransactionHandler.js.map
@@ -1682,6 +1656,9 @@ packages/server/src/utils/routeUtils.js.map
packages/server/src/utils/routeUtils.test.d.ts
packages/server/src/utils/routeUtils.test.js
packages/server/src/utils/routeUtils.test.js.map
packages/server/src/utils/testing/apiUtils.d.ts
packages/server/src/utils/testing/apiUtils.js
packages/server/src/utils/testing/apiUtils.js.map
packages/server/src/utils/testing/koa/FakeCookies.d.ts
packages/server/src/utils/testing/koa/FakeCookies.js
packages/server/src/utils/testing/koa/FakeCookies.js.map

84
.gitignore vendored
View File

@@ -1467,51 +1467,6 @@ packages/server/src/config-tests.js.map
packages/server/src/config.d.ts
packages/server/src/config.js
packages/server/src/config.js.map
packages/server/src/controllers/BaseController.d.ts
packages/server/src/controllers/BaseController.js
packages/server/src/controllers/BaseController.js.map
packages/server/src/controllers/api/FileController.d.ts
packages/server/src/controllers/api/FileController.js
packages/server/src/controllers/api/FileController.js.map
packages/server/src/controllers/api/FileController.test.d.ts
packages/server/src/controllers/api/FileController.test.js
packages/server/src/controllers/api/FileController.test.js.map
packages/server/src/controllers/api/OAuthController.d.ts
packages/server/src/controllers/api/OAuthController.js
packages/server/src/controllers/api/OAuthController.js.map
packages/server/src/controllers/api/SessionController.d.ts
packages/server/src/controllers/api/SessionController.js
packages/server/src/controllers/api/SessionController.js.map
packages/server/src/controllers/api/SessionController.test.d.ts
packages/server/src/controllers/api/SessionController.test.js
packages/server/src/controllers/api/SessionController.test.js.map
packages/server/src/controllers/api/UserController.d.ts
packages/server/src/controllers/api/UserController.js
packages/server/src/controllers/api/UserController.js.map
packages/server/src/controllers/api/UserController.test.d.ts
packages/server/src/controllers/api/UserController.test.js
packages/server/src/controllers/api/UserController.test.js.map
packages/server/src/controllers/factory.d.ts
packages/server/src/controllers/factory.js
packages/server/src/controllers/factory.js.map
packages/server/src/controllers/index/FileController.d.ts
packages/server/src/controllers/index/FileController.js
packages/server/src/controllers/index/FileController.js.map
packages/server/src/controllers/index/HomeController.d.ts
packages/server/src/controllers/index/HomeController.js
packages/server/src/controllers/index/HomeController.js.map
packages/server/src/controllers/index/LoginController.d.ts
packages/server/src/controllers/index/LoginController.js
packages/server/src/controllers/index/LoginController.js.map
packages/server/src/controllers/index/NotificationController.d.ts
packages/server/src/controllers/index/NotificationController.js
packages/server/src/controllers/index/NotificationController.js.map
packages/server/src/controllers/index/ProfileController.d.ts
packages/server/src/controllers/index/ProfileController.js
packages/server/src/controllers/index/ProfileController.js.map
packages/server/src/controllers/index/UserController.d.ts
packages/server/src/controllers/index/UserController.js
packages/server/src/controllers/index/UserController.js.map
packages/server/src/db.d.ts
packages/server/src/db.js
packages/server/src/db.js.map
@@ -1569,6 +1524,9 @@ packages/server/src/models/SessionModel.js.map
packages/server/src/models/UserModel.d.ts
packages/server/src/models/UserModel.js
packages/server/src/models/UserModel.js.map
packages/server/src/models/UserModel.test.d.ts
packages/server/src/models/UserModel.test.js
packages/server/src/models/UserModel.test.js.map
packages/server/src/models/factory.d.ts
packages/server/src/models/factory.js
packages/server/src/models/factory.js.map
@@ -1581,9 +1539,9 @@ packages/server/src/models/utils/pagination.test.js.map
packages/server/src/routes/api/files.d.ts
packages/server/src/routes/api/files.js
packages/server/src/routes/api/files.js.map
packages/server/src/routes/api/index.d.ts
packages/server/src/routes/api/index.js
packages/server/src/routes/api/index.js.map
packages/server/src/routes/api/files.test.d.ts
packages/server/src/routes/api/files.test.js
packages/server/src/routes/api/files.test.js.map
packages/server/src/routes/api/ping.d.ts
packages/server/src/routes/api/ping.js
packages/server/src/routes/api/ping.js.map
@@ -1593,6 +1551,9 @@ packages/server/src/routes/api/ping.test.js.map
packages/server/src/routes/api/sessions.d.ts
packages/server/src/routes/api/sessions.js
packages/server/src/routes/api/sessions.js.map
packages/server/src/routes/api/sessions.test.d.ts
packages/server/src/routes/api/sessions.test.js
packages/server/src/routes/api/sessions.test.js.map
packages/server/src/routes/default.d.ts
packages/server/src/routes/default.js
packages/server/src/routes/default.js.map
@@ -1602,24 +1563,33 @@ packages/server/src/routes/index/files.js.map
packages/server/src/routes/index/home.d.ts
packages/server/src/routes/index/home.js
packages/server/src/routes/index/home.js.map
packages/server/src/routes/index/home.test.d.ts
packages/server/src/routes/index/home.test.js
packages/server/src/routes/index/home.test.js.map
packages/server/src/routes/index/login.d.ts
packages/server/src/routes/index/login.js
packages/server/src/routes/index/login.js.map
packages/server/src/routes/index/login.test.d.ts
packages/server/src/routes/index/login.test.js
packages/server/src/routes/index/login.test.js.map
packages/server/src/routes/index/logout.d.ts
packages/server/src/routes/index/logout.js
packages/server/src/routes/index/logout.js.map
packages/server/src/routes/index/logout.test.d.ts
packages/server/src/routes/index/logout.test.js
packages/server/src/routes/index/logout.test.js.map
packages/server/src/routes/index/notifications.d.ts
packages/server/src/routes/index/notifications.js
packages/server/src/routes/index/notifications.js.map
packages/server/src/routes/index/profile.d.ts
packages/server/src/routes/index/profile.js
packages/server/src/routes/index/profile.js.map
packages/server/src/routes/index/user.d.ts
packages/server/src/routes/index/user.js
packages/server/src/routes/index/user.js.map
packages/server/src/routes/index/notifications.test.d.ts
packages/server/src/routes/index/notifications.test.js
packages/server/src/routes/index/notifications.test.js.map
packages/server/src/routes/index/users.d.ts
packages/server/src/routes/index/users.js
packages/server/src/routes/index/users.js.map
packages/server/src/routes/index/users.test.d.ts
packages/server/src/routes/index/users.test.js
packages/server/src/routes/index/users.test.js.map
packages/server/src/routes/oauth2/authorize.d.ts
packages/server/src/routes/oauth2/authorize.js
packages/server/src/routes/oauth2/authorize.js.map
@@ -1638,6 +1608,9 @@ packages/server/src/tools/dbTools.js.map
packages/server/src/tools/generate-types.d.ts
packages/server/src/tools/generate-types.js
packages/server/src/tools/generate-types.js.map
packages/server/src/utils/Router.d.ts
packages/server/src/utils/Router.js
packages/server/src/utils/Router.js.map
packages/server/src/utils/TransactionHandler.d.ts
packages/server/src/utils/TransactionHandler.js
packages/server/src/utils/TransactionHandler.js.map
@@ -1671,6 +1644,9 @@ packages/server/src/utils/routeUtils.js.map
packages/server/src/utils/routeUtils.test.d.ts
packages/server/src/utils/routeUtils.test.js
packages/server/src/utils/routeUtils.test.js.map
packages/server/src/utils/testing/apiUtils.d.ts
packages/server/src/utils/testing/apiUtils.js
packages/server/src/utils/testing/apiUtils.js.map
packages/server/src/utils/testing/koa/FakeCookies.d.ts
packages/server/src/utils/testing/koa/FakeCookies.js
packages/server/src/utils/testing/koa/FakeCookies.js.map

View File

@@ -1,3 +0,0 @@
FROM postgres:13.1
EXPOSE 5432

View File

@@ -16,8 +16,15 @@ WORKDIR /home/$user
RUN mkdir /home/$user/logs
# 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 ./
RUN npm install --ignore-scripts
# 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
# 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
@@ -27,19 +34,10 @@ RUN mkdir /home/$user/logs
# 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 package*.json ./
# 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)
RUN npm install --ignore-scripts
COPY --chown=$user:$user packages/fork-sax/package*.json ./packages/fork-sax/
COPY --chown=$user:$user packages/lib/package*.json ./packages/lib/
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/server/package*.json ./packages/server/
COPY --chown=$user:$user packages/lib/package*.json ./packages/lib/
COPY --chown=$user:$user lerna.json .
COPY --chown=$user:$user tsconfig.json .
@@ -50,22 +48,29 @@ 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
RUN ls -la /home/$user
# Then bootstrap only, without compiling the TypeScript files
RUN npm run bootstrap
# We have a separate step for the server files because they are more likely to
# change.
COPY --chown=$user:$user packages/server/package*.json ./packages/server/
RUN npm run bootstrapServerOnly
# 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/lib ./packages/lib
COPY --chown=$user:$user packages/renderer ./packages/renderer
COPY --chown=$user:$user packages/tools ./packages/tools
COPY --chown=$user:$user packages/lib ./packages/lib
COPY --chown=$user:$user packages/server ./packages/server
# Finally build everything, in particular the TypeScript files.
RUN npm run build
EXPOSE ${JOPLIN_PORT}
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
CMD [ "npm", "--prefix", "packages/server", "start" ]

View File

@@ -162,7 +162,7 @@ DESKTOP=${DESKTOP,,} # convert to lower case
#-----------------------------------------------------
echo 'Create Desktop icon...'
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.* ]]
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.*|.*sway.* ]]
then
# Only delete the desktop file if it will be replaced
rm -f ~/.local/share/applications/appimagekit-joplin.desktop

15
docker-compose.db-dev.yml Normal file
View File

@@ -0,0 +1,15 @@
# For development this compose file starts the database only. The app can then
# be started using `npm run start-dev`, which is useful for development, because
# it means the app Docker file doesn't have to be rebuilt on each change.
version: '3'
services:
db:
image: postgres:13.1
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=joplin
- POSTGRES_USER=joplin
- POSTGRES_DB=joplin

View File

@@ -1,28 +1,27 @@
# For development, the easiest might be to only start the Postgres container and
# run the app directly with `npm start`. Or use sqlite3.
# This compose file can be used in development to run both the database and app
# within Docker.
version: '3'
services:
# app:
# build:
# context: .
# dockerfile: Dockerfile.server-dev
# ports:
# - "22300:22300"
# # volumes:
# # - ./packages/server/:/var/www/joplin/packages/server/
# # - /var/www/joplin/packages/server/node_modules/
db:
app:
build:
context: .
dockerfile: Dockerfile.db
dockerfile: Dockerfile.server
ports:
- "22300:22300"
environment:
- DB_CLIENT=pg
- POSTGRES_PASSWORD=joplin
- POSTGRES_DATABASE=joplin
- POSTGRES_USER=joplin
- POSTGRES_PORT=5432
- POSTGRES_HOST=localhost
db:
image: postgres:13.1
ports:
- "5432:5432"
environment:
# TODO: Considering the database is only exposed to the
# application, and not to the outside world, is there a need to
# pick a secure password?
- POSTGRES_PASSWORD=joplin
- POSTGRES_USER=joplin
- POSTGRES_DB=joplin
- POSTGRES_DB=joplin

View File

@@ -1,40 +1,34 @@
# This is a sample docker-compose file that can be used to run Joplin Server
# along with a PostgreSQL server.
#
# All environment variables are optional. If you don't set them, you will get a
# warning from docker-compose, however the app should use working defaults.
version: '3'
services:
app:
environment:
- JOPLIN_BASE_URL=${JOPLIN_BASE_URL}
- JOPLIN_PORT=${JOPLIN_PORT}
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile.server
ports:
- "${JOPLIN_PORT}:${JOPLIN_PORT}"
# volumes:
# # Mount the server directory so that it's possible to edit file
# # while the container is running. However don't mount the
# # node_modules directory which will be specific to the Docker
# # image (eg native modules will be built for Ubuntu, while the
# # container might be running in Windows)
# # https://stackoverflow.com/a/37898591/561309
# - ./packages/server:/home/joplin/packages/server
# - /home/joplin/packages/server/node_modules/
db:
restart: unless-stopped
# By default, the Postgres image saves the data to a Docker volume,
# so it persists whenever the server is restarted using
# `docker-compose up`. Note that it would however be deleted when
# running `docker-compose down`.
build:
context: .
dockerfile: Dockerfile.db
image: postgres:13.1
ports:
- "5432:5432"
restart: unless-stopped
environment:
# TODO: Considering the database is only exposed to the
# application, and not to the outside world, is there a need to
# pick a secure password?
- POSTGRES_PASSWORD=joplin
- POSTGRES_USER=joplin
- POSTGRES_DB=joplin
- APP_PORT=22300
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_DB=${POSTGRES_DATABASE}
app:
image: joplin/server:latest
depends_on:
- db
ports:
- "22300:22300"
restart: unless-stopped
environment:
- APP_BASE_URL=${APP_BASE_URL}
- DB_CLIENT=pg
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DATABASE=${POSTGRES_DATABASE}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_HOST=db

View File

@@ -8,6 +8,7 @@
"license": "MIT",
"scripts": {
"bootstrap": "lerna bootstrap --no-ci",
"bootstrapServerOnly": "lerna bootstrap --no-ci --include-dependents --include-dependencies --scope @joplin/server",
"bootstrapIgnoreScripts": "lerna bootstrap --ignore-scripts --no-ci",
"build": "lerna run build && npm run tsc",
"buildApiDoc": "npm start --prefix=packages/app-cli -- apidoc ../../readme/api/references/rest_api.md",

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "1.6.7",
"version": "1.7.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -586,4 +586,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: a0d1ca4e385ef46f9103f02206ebf612107dd508
COCOAPODS: 1.9.3
COCOAPODS: 1.10.1

View File

@@ -233,7 +233,7 @@ function main(processArgv) {
const configs = {
// Builds the main src/index.ts and copy the extra content from /src to
// /dist including scripts, CSS and any other asset.
buildMain: pluginConfig,
buildMain: [pluginConfig],
// Builds the extra scripts as defined in plugin.config.json. When doing
// so, some JavaScript files that were copied in the previous might be
@@ -247,7 +247,7 @@ function main(processArgv) {
// run without this. So we give it an entry that we know is going to
// exist and output in the publish dir. Then the plugin will delete this
// temporary file before packaging the plugin.
createArchive: createArchiveConfig,
createArchive: [createArchiveConfig],
};
// If we are running the first config step, we clean up and create the build
@@ -270,4 +270,10 @@ try {
process.exit(1);
}
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
module.exports = exportedConfigs;

View File

@@ -1,6 +1,6 @@
{
"name": "generator-joplin",
"version": "1.7.1",
"version": "1.7.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "generator-joplin",
"version": "1.7.1",
"version": "1.7.3",
"description": "Scaffolds out a new Joplin plugin",
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
"author": {

View File

@@ -52,15 +52,15 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
}
static async checkConfig(options: FileApiOptions) {
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
fileApi.requestRepeatCount_ = 0;
const output = {
ok: false,
errorMessage: '',
};
try {
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
fileApi.requestRepeatCount_ = 0;
const result = await fileApi.stat('');
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
output.ok = true;

View File

@@ -4,64 +4,86 @@
First copy `.env-sample` to `.env` and edit the values in there:
- `JOPLIN_BASE_URL`: This is the base public URL where the service will be running. For example, if you want it to run from `https://example.com/joplin`, this is what you should set the URL to. The base URL can include the port.
- `JOPLIN_PORT`: The local port on which the Docker container will listen. You would typically map this port to 443 (TLS) with a reverse proxy.
- `APP_BASE_URL`: This is the base public URL where the service will be running. For example, if you want it to run from `https://example.com/joplin`, this is what you should set the URL to. The base URL can include the port.
- `APP_PORT`: The local port on which the Docker container will listen. You would typically map this port to 443 (TLS) with a reverse proxy.
## Install application
## Running the server
To start the server with default configuration, run:
```shell
wget https://github.com/laurent22/joplin/archive/server-v1.6.4.tar.gz
tar xzvf server-v1.6.4.tar.gz
mv joplin-server-v1.6.4 joplin-server
cd joplin-server
docker-compose --file docker-compose.server.yml up --detach
docker run --env-file .env -p 22300:22300 joplin/server:latest
```
This will start the server, which will listen on port **22300** on **localhost**.
This will start the server, which will listen on port **22300** on **localhost**. By default it will use SQLite, which allows you to test the app without setting up a database. To run it for production though, you'll want to connect the container to a database, as described below.
Due to the restart policy defined in the docker-compose file, the server will be restarted automatically whenever the host reboots.
## Setup the database
You can setup the container to either use an existing PostgreSQL server, or connect it to a new one using docker-compose
### Using an existing PostgreSQL server
To use an existing PostgresSQL server, set the following environment variables in the .env file:
```conf
DB_CLIENT=pg
POSTGRES_PASSWORD=joplin
POSTGRES_DATABASE=joplin
POSTGRES_USER=joplin
POSTGRES_PORT=5432
POSTGRES_HOST=localhost
```
Make sure that the provided database and user exist as the server will not create them.
### Using docker-compose
A [sample docker-compose file](https://github.com/laurent22/joplin/blob/dev/docker-compose.server.yml
) is available to show how to use Docker to install both the database and server and connect them:
## Setup reverse proxy
You will then need to expose this server to the internet by setting up a reverse proxy, and that will depend on how your server is currently configured, and whether you already have Nginx or Apache running:
Once Joplin Server is running, you will then need to expose it to the internet by setting up a reverse proxy, and that will depend on how your server is currently configured, and whether you already have Nginx or Apache running:
- [Apache Reverse Proxy](https://httpd.apache.org/docs/current/mod/mod_proxy.html)
- [Nginx Reverse Proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/)
## Setup admin user
## Setup the website
For the following instructions, we'll assume that the Joplin server is running on `https://example.com/joplin`.
Once the server is exposed to the internet, you can open the admin UI and get it ready for synchronisation. For the following instructions, we'll assume that the Joplin server is running on `https://example.com/joplin`.
By default, the instance will be setup with an admin user with email **admin@localhost** and password **admin** and you should change this by opening the admin UI. To do so, open `https://example.com/joplin/login`. From there, go to Profile and change the admin password.
### Secure the admin user
## Setup a user for sync
By default, the instance will be setup with an admin user with email **admin@localhost** and password **admin** and you should change this. To do so, open `https://example.com/joplin/login` and login as admin. Then go to the Profile section and change the admin password.
While the admin user can be used for synchronisation, it is recommended to create a separate non-admin user for it. To do, open the admin UI and navigate to the Users page - from there you can create a new user.
### Create a user for sync
Once this is done, you can use the email and password you specified to sync this user account with your Joplin clients.
While the admin user can be used for synchronisation, it is recommended to create a separate non-admin user for it. To do so, navigate to the Users page - from there you can create a new user. Once this is done, you can use the email and password you specified to sync this user account with your Joplin clients.
## Checking the logs
Checking the log can be done the standard Docker way:
```shell
```bash
# With Docker:
docker logs --follow CONTAINER
# With docker-compose:
docker-compose --file docker-compose.server.yml logs
```
# Set up for development
# Setup for development
## Setting up the database
## Setup up the database
### SQLite
The server supports SQLite for development and test units. To use it, open `src/config-dev.ts` and uncomment the sqlite3 config.
By default the server supports SQLite for development, so nothing needs to be setup.
### PostgreSQL
It's best to use PostgreSQL as this is what is used in production, however it requires Docker.
To use it, from the monorepo root, run `docker-compose --file docker-compose.server-dev.yml up`, which will start the PostgreSQL database.
To use Postgres, from the monorepo root, run `docker-compose --file docker-compose.server-dev.yml up`, which will start the PostgreSQL database.
## Starting the server
From `packages/server`, run `npm run start-dev`
From `packages/server`, run `npm run start-dev`

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "1.6.4",
"version": "1.7.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1308,6 +1308,17 @@
"pretty-format": "^26.0.0"
}
},
"@types/jsdom": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.6.tgz",
"integrity": "sha512-yQA+HxknGtW9AkRTNyiSH3OKW5V+WzO8OPTdne99XwJkYC+KYxfNIcoJjeiSqP3V00PUUpFP6Myoo9wdIu78DQ==",
"dev": true,
"requires": {
"@types/node": "*",
"@types/parse5": "*",
"@types/tough-cookie": "*"
}
},
"@types/keygrip": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.1.tgz",
@@ -1384,6 +1395,12 @@
"integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==",
"dev": true
},
"@types/parse5": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.0.tgz",
"integrity": "sha512-oPwPSj4a1wu9rsXTEGIJz91ISU725t0BmSnUhb57sI+M8XEmvUop84lzuiYdq0Y5M6xLY8DBPg0C2xEQKLyvBA==",
"dev": true
},
"@types/prettier": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.1.5.tgz",
@@ -1412,6 +1429,12 @@
"integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==",
"dev": true
},
"@types/tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz",
"integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==",
"dev": true
},
"@types/yargs": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.2.tgz",
@@ -5915,6 +5938,11 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-env-file": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/node-env-file/-/node-env-file-0.1.8.tgz",
"integrity": "sha1-/Mt7BQ9zW1oz2p65N89vGrRX+2k="
},
"node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "1.7.0",
"version": "1.7.1",
"private": true,
"scripts": {
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
@@ -26,6 +26,7 @@
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",
"nanoid": "^2.1.1",
"node-env-file": "^0.1.8",
"nodemon": "^2.0.6",
"pg": "^8.5.1",
"query-string": "^6.8.3",
@@ -37,11 +38,13 @@
"@rmp135/sql-ts": "^1.7.0",
"@types/fs-extra": "^8.0.0",
"@types/jest": "^26.0.15",
"@types/jsdom": "^16.2.6",
"@types/koa": "^2.0.49",
"@types/markdown-it": "^12.0.0",
"@types/mustache": "^0.8.32",
"@types/yargs": "^13.0.2",
"jest": "^26.6.3",
"jsdom": "^16.4.0",
"node-mocks-http": "^1.10.0",
"source-map-support": "^0.5.13",
"typescript": "^4.1.2"

View File

@@ -5,33 +5,30 @@ import * as Koa from 'koa';
import * as fs from 'fs-extra';
import { argv } from 'yargs';
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
import config, { initConfig, baseUrl } from './config';
import configDev from './config-dev';
import configProd from './config-prod';
import configBuildTypes from './config-buildTypes';
import config, { initConfig, runningInDocker, EnvVariables } from './config';
import { createDb, dropDb } from './tools/dbTools';
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection } from './db';
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection, sqliteFilePath } from './db';
import modelFactory from './models/factory';
import controllerFactory from './controllers/factory';
import { AppContext, Config, Env } from './utils/types';
import { AppContext, Env } from './utils/types';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import routeHandler from './middleware/routeHandler';
import notificationHandler from './middleware/notificationHandler';
import ownerHandler from './middleware/ownerHandler';
const nodeEnvFile = require('node-env-file');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
shimInit();
const env: Env = argv.env as Env || Env.Prod;
interface Configs {
[name: string]: Config;
}
const configs: Configs = {
dev: configDev,
prod: configProd,
buildTypes: configBuildTypes,
const envVariables: Record<Env, EnvVariables> = {
dev: {
SQLITE_DATABASE: 'dev',
},
buildTypes: {
SQLITE_DATABASE: 'buildTypes',
},
prod: {}, // Actually get the env variables from the environment
};
let appLogger_: LoggerWrapper = null;
@@ -53,11 +50,31 @@ app.use(ownerHandler);
app.use(notificationHandler);
app.use(routeHandler);
async function main() {
const configObject: Config = configs[env];
if (!configObject) throw new Error(`Invalid env: ${env}`);
function markPasswords(o: Record<string, any>): Record<string, any> {
const output: Record<string, any> = {};
initConfig(configObject);
for (const k of Object.keys(o)) {
if (k.toLowerCase().includes('password')) {
output[k] = '********';
} else {
output[k] = o[k];
}
}
return output;
}
async function main() {
if (argv.envFile) {
nodeEnvFile(argv.envFile);
}
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
initConfig({
...envVariables[env],
...process.env,
});
await fs.mkdirp(config().logDir);
Logger.fsDriver_ = new FsDriverNode();
@@ -91,8 +108,11 @@ async function main() {
await createDb(config().database);
} else {
appLogger().info(`Starting server (${env}) on port ${config().port} and PID ${process.pid}...`);
appLogger().info('Public base URL:', baseUrl());
appLogger().info('DB Config:', config().database);
appLogger().info('Running in Docker:', runningInDocker());
appLogger().info('Public base URL:', config().baseUrl);
appLogger().info('Log dir:', config().logDir);
appLogger().info('DB Config:', markPasswords(config().database));
if (config().database.client === 'sqlite3') appLogger().info('DB file:', sqliteFilePath(config().database.name));
const appContext = app.context as AppContext;
@@ -105,14 +125,13 @@ async function main() {
appLogger().info('Connection check:', connectionCheckLogInfo);
appContext.env = env;
appContext.db = connectionCheck.connection;
appContext.models = modelFactory(appContext.db, baseUrl());
appContext.controllers = controllerFactory(appContext.models);
appContext.models = modelFactory(appContext.db, config().baseUrl);
appContext.appLogger = appLogger;
appLogger().info('Migrating database...');
await migrateDb(appContext.db);
appLogger().info(`Call this for testing: \`curl ${baseUrl()}/api/ping\``);
appLogger().info(`Call this for testing: \`curl ${config().baseUrl}/api/ping\``);
app.listen(config().port);
}

View File

@@ -1,21 +0,0 @@
import { Config } from './utils/types';
import * as pathUtils from 'path';
const rootDir = pathUtils.dirname(__dirname);
const viewDir = `${pathUtils.dirname(__dirname)}/src/views`;
const envPort = Number(process.env.JOPLIN_PORT);
const config: Config = {
port: (envPort && !isNaN(envPort)) ? envPort : 22300,
viewDir: viewDir,
rootDir: rootDir,
layoutDir: `${viewDir}/layouts`,
logDir: `${rootDir}/logs`,
database: {
client: 'pg',
name: 'joplin',
},
};
export default config;

View File

@@ -1,13 +0,0 @@
import { Config } from './utils/types';
import configBase from './config-base';
const config: Config = {
...configBase,
database: {
name: 'buildTypes',
client: 'sqlite3',
asyncStackTraces: true,
},
};
export default config;

View File

@@ -1,22 +0,0 @@
import { Config } from './utils/types';
import configBase from './config-base';
const config: Config = {
...configBase,
database: {
name: 'dev',
client: 'sqlite3',
asyncStackTraces: true,
},
// database: {
// client: 'pg',
// name: 'joplin',
// user: 'joplin',
// host: 'localhost',
// port: 5432,
// password: 'joplin',
// asyncStackTraces: true,
// },
};
export default config;

View File

@@ -1,20 +0,0 @@
import { Config } from './utils/types';
import configBase from './config-base';
const rootDir = '/home/joplin/';
const config: Config = {
...configBase,
rootDir: rootDir,
logDir: `${rootDir}/logs`,
database: {
client: 'pg',
name: 'joplin',
user: 'joplin',
host: 'db',
port: 5432,
password: 'joplin',
},
};
export default config;

View File

@@ -1,13 +0,0 @@
import { Config } from './utils/types';
import configBase from './config-base';
const config: Config = {
...configBase,
database: {
name: 'DYNAMIC',
client: 'sqlite3',
asyncStackTraces: true,
},
};
export default config;

View File

@@ -1,28 +1,93 @@
import { rtrimSlashes } from '@joplin/lib/path-utils';
import { Config } from './utils/types';
import { Config, DatabaseConfig, DatabaseConfigClient } from './utils/types';
import * as pathUtils from 'path';
let baseConfig_: Config = null;
let baseUrl_: string = null;
export interface EnvVariables {
APP_BASE_URL?: string;
APP_PORT?: string;
DB_CLIENT?: string;
RUNNING_IN_DOCKER?: string;
export function initConfig(baseConfig: Config) {
baseConfig_ = baseConfig;
POSTGRES_PASSWORD?: string;
POSTGRES_DATABASE?: string;
POSTGRES_USER?: string;
POSTGRES_HOST?: string;
POSTGRES_PORT?: string;
SQLITE_DATABASE?: string;
}
let runningInDocker_: boolean = false;
export function runningInDocker(): boolean {
return runningInDocker_;
}
function databaseHostFromEnv(runningInDocker: boolean, env: EnvVariables): string {
if (env.POSTGRES_HOST) {
// When running within Docker, the app localhost is different from the
// host's localhost. To access the latter, Docker defines a special host
// called "host.docker.internal", so here we swap the values if necessary.
if (runningInDocker && ['localhost', '127.0.0.1'].includes(env.POSTGRES_HOST)) {
return 'host.docker.internal';
} else {
return env.POSTGRES_HOST;
}
}
return null;
}
function databaseConfigFromEnv(runningInDocker: boolean, env: EnvVariables): DatabaseConfig {
if (env.DB_CLIENT === 'pg') {
return {
client: DatabaseConfigClient.PostgreSQL,
name: env.POSTGRES_DATABASE || 'joplin',
user: env.POSTGRES_USER || 'joplin',
password: env.POSTGRES_PASSWORD || 'joplin',
port: env.POSTGRES_PORT ? Number(env.POSTGRES_PORT) : 5432,
host: databaseHostFromEnv(runningInDocker, env) || 'localhost',
};
}
return {
client: DatabaseConfigClient.SQLite,
name: env.SQLITE_DATABASE || 'prod',
asyncStackTraces: true,
};
}
function baseUrlFromEnv(env: any, appPort: number): string {
if (env.APP_BASE_URL) {
return rtrimSlashes(env.APP_BASE_URL);
} else {
return `http://localhost:${appPort}`;
}
}
let config_: Config = null;
export function initConfig(env: EnvVariables) {
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
const rootDir = pathUtils.dirname(__dirname);
const viewDir = `${pathUtils.dirname(__dirname)}/src/views`;
const appPort = env.APP_PORT ? Number(env.APP_PORT) : 22300;
config_ = {
rootDir: rootDir,
viewDir: viewDir,
layoutDir: `${viewDir}/layouts`,
logDir: `${rootDir}/logs`,
database: databaseConfigFromEnv(runningInDocker_, env),
port: appPort,
baseUrl: baseUrlFromEnv(env, appPort),
};
}
function config(): Config {
if (!baseConfig_) throw new Error('Config has not been initialized!');
return baseConfig_;
}
export function baseUrl() {
if (baseUrl_) return baseUrl_;
if (process.env.JOPLIN_BASE_URL) {
baseUrl_ = rtrimSlashes(process.env.JOPLIN_BASE_URL);
} else {
baseUrl_ = `http://localhost:${config().port}`;
}
return baseUrl_;
if (!config_) throw new Error('Config has not been initialized!');
return config_;
}
export default config;

View File

@@ -1,25 +0,0 @@
import { User } from '../db';
import { Models } from '../models/factory';
import { ErrorForbidden } from '../utils/errors';
export default abstract class BaseController {
private models_: Models;
public constructor(models: Models) {
this.models_ = models;
}
protected get models(): Models {
return this.models_;
}
protected async initSession(sessionId: string, mustBeAdmin: boolean = false): Promise<User> {
if (!sessionId) throw new ErrorForbidden('Session is required');
const user: User = await this.models.session().sessionUser(sessionId);
if (!user) throw new ErrorForbidden(`Invalid session ID: ${sessionId}`);
if (!user.is_admin && mustBeAdmin) throw new ErrorForbidden('Non-admin user is not allowed');
return user;
}
}

View File

@@ -1,535 +0,0 @@
import { testAssetDir, createUserAndSession, createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, models, controllers } from '../../utils/testing/testUtils';
import * as fs from 'fs-extra';
import { ChangeType, File } from '../../db';
import { ErrorConflict, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors';
import { filePathInfo } from '../../utils/routeUtils';
import { defaultPagination, Pagination, PaginationOrderDir } from '../../models/utils/pagination';
import { msleep } from '../../utils/time';
async function makeTestFile(id: number = 1, ext: string = 'jpg', parentId: string = ''): Promise<File> {
const basename = ext === 'jpg' ? 'photo' : 'poster';
const file: File = {
name: id > 1 ? `${basename}-${id}.${ext}` : `${basename}.${ext}`,
content: await fs.readFile(`${testAssetDir}/${basename}.${ext}`),
// mime_type: `image/${ext}`,
parent_id: parentId,
};
return file;
}
async function makeTestContent(ext: string = 'jpg') {
const basename = ext === 'jpg' ? 'photo' : 'poster';
return await fs.readFile(`${testAssetDir}/${basename}.${ext}`);
}
async function makeTestDirectory(name: string = 'Docs'): Promise<File> {
const file: File = {
name: name,
parent_id: '',
is_directory: 1,
};
return file;
}
async function saveTestFile(sessionId: string, path: string): Promise<File> {
const fileController = controllers().apiFile();
return fileController.putFileContent(
sessionId,
path,
null
);
}
async function saveTestDir(sessionId: string, path: string): Promise<File> {
const fileController = controllers().apiFile();
const parsed = filePathInfo(path);
return fileController.postChild(
sessionId,
parsed.dirname,
{
name: parsed.basename,
is_directory: 1,
}
);
}
describe('FileController', function() {
beforeAll(async () => {
await beforeAllDb('FileController');
});
afterAll(async () => {
await afterAllDb();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileContent = await makeTestContent();
const newFile = await fileController.putFileContent(
session.id,
'root:/photo.jpg:',
fileContent
);
expect(!!newFile.id).toBe(true);
expect(newFile.name).toBe('photo.jpg');
expect(newFile.mime_type).toBe('image/jpeg');
expect(!!newFile.parent_id).toBe(true);
expect(!newFile.content).toBe(true);
expect(newFile.size > 0).toBe(true);
const fileModel = models().file({ userId: user.id });
const newFileReload = await fileModel.loadWithContent(newFile.id);
expect(!!newFileReload).toBe(true);
const newFileHex = fileContent.toString('hex');
const newFileReloadHex = (newFileReload.content as Buffer).toString('hex');
expect(newFileReloadHex.length > 0).toBe(true);
expect(newFileReloadHex).toBe(newFileHex);
});
test('should create sub-directories', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const newDir = await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir',
});
expect(!!newDir.id).toBe(true);
expect(newDir.is_directory).toBe(1);
const newDir2 = await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir2',
parent_id: newDir.id,
});
const newDirReload2 = await fileController.getFile(session.id, 'root:/subdir/subdir2');
expect(newDirReload2.id).toBe(newDir2.id);
expect(newDirReload2.name).toBe(newDir2.name);
});
test('should create files in sub-directory', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
await fileController.postFile_(session.id, {
is_directory: 1,
name: 'subdir',
});
const newFile = await fileController.putFileContent(
session.id,
'root:/subdir/photo.jpg:',
await makeTestContent()
);
const newFileReload = await fileController.getFile(session.id, 'root:/subdir/photo.jpg');
expect(newFileReload.id).toBe(newFile.id);
expect(newFileReload.name).toBe('photo.jpg');
});
test('should not create a file with an invalid path', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileContent = await makeTestContent();
const error = await checkThrowAsync(async () => fileController.putFileContent(
session.id,
'root:/does/not/exist/photo.jpg:',
fileContent
));
expect(error instanceof ErrorNotFound).toBe(true);
});
test('should get files', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
let file3: File = await makeTestFile(3);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
file2 = await fileController.postFile_(session1.id, file2);
file3 = await fileController.postFile_(session2.id, file3);
const fileId1 = file1.id;
const fileId2 = file2.id;
// Can't get someone else file
const error = await checkThrowAsync(async () => fileController.getFile(session1.id, file3.id));
expect(error instanceof ErrorForbidden).toBe(true);
file1 = await fileController.getFile(session1.id, file1.id);
expect(file1.id).toBe(fileId1);
const fileModel = models().file({ userId: user1.id });
const paginatedResults = await fileController.getChildren(session1.id, await fileModel.userRootFileId(), defaultPagination());
const allFiles = paginatedResults.items;
expect(allFiles.length).toBe(2);
expect(JSON.stringify(allFiles.map(f => f.id).sort())).toBe(JSON.stringify([fileId1, fileId2].sort()));
});
test('should not let create a file in a directory not owned by user', async function() {
const { session } = await createUserAndSession(1);
const user2 = await createUser(2);
const fileModel2 = models().file({ userId: user2.id });
const rootFile2 = await fileModel2.userRootFile();
const file: File = await makeTestFile();
file.parent_id = rootFile2.id;
const fileController = controllers().apiFile();
const hasThrown = await checkThrowAsync(async () => fileController.postFile_(session.id, file));
expect(!!hasThrown).toBe(true);
});
test('should update file properties', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
let file: File = await makeTestFile();
const fileController = controllers().apiFile();
file = await fileController.postFile_(session.id, file);
// Can't have file with empty name
const error = await checkThrowAsync(async () => fileController.patchFile(session.id, file.id, { name: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
await fileController.patchFile(session.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await fileController.patchFile(session.id, file.id, { mime_type: 'image/png' });
file = await fileModel.load(file.id);
expect(file.mime_type).toBe('image/png');
});
test('should not allow duplicate filenames', async function() {
const { session } = await createUserAndSession(1, true);
let file1: File = await makeTestFile(1);
const file2: File = await makeTestFile(1);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session.id, file1);
expect(!!file1.id).toBe(true);
expect(file1.name).toBe(file2.name);
const hasThrown = await checkThrowAsync(async () => await fileController.postFile_(session.id, file2));
expect(!!hasThrown).toBe(true);
});
test('should change the file parent', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
let hasThrown: any = null;
const fileModel = models().file({ userId: user1.id });
let file: File = await makeTestFile();
let file2: File = await makeTestFile(2);
let dir: File = await makeTestDirectory();
const fileController = controllers().apiFile();
file = await fileController.postFile_(session1.id, file);
file2 = await fileController.postFile_(session1.id, file2);
dir = await fileController.postFile_(session1.id, dir);
// Can't set parent to another non-directory file
hasThrown = await checkThrowAsync(async () => await fileController.patchFile(session1.id, file.id, { parent_id: file2.id }));
expect(!!hasThrown).toBe(true);
const fileModel2 = models().file({ userId: user2.id });
const userRoot2 = await fileModel2.userRootFile();
// Can't set parent to someone else directory
hasThrown = await checkThrowAsync(async () => await fileController.patchFile(session1.id, file.id, { parent_id: userRoot2.id }));
expect(!!hasThrown).toBe(true);
await fileController.patchFile(session1.id, file.id, { parent_id: dir.id });
file = await fileModel.load(file.id);
expect(!!file.parent_id).toBe(true);
expect(file.parent_id).toBe(dir.id);
});
test('should delete a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
await fileController.postFile_(session.id, file1);
file2 = await fileController.postFile_(session.id, file2);
let allFiles: File[] = await fileModel.all();
const beforeCount: number = allFiles.length;
await fileController.deleteFile(session.id, file2.id);
allFiles = await fileModel.all();
expect(allFiles.length).toBe(beforeCount - 1);
});
test('should create and delete directories', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const dir1: File = await fileController.postChild(session.id, 'root', { name: 'dir1', is_directory: 1 });
const dir2: File = await fileController.postChild(session.id, 'root:/dir1', { name: 'dir2', is_directory: 1 });
const dirReload2: File = await fileController.getFile(session.id, 'root:/dir1/dir2');
expect(dirReload2.id).toBe(dir2.id);
// Delete one directory
await fileController.deleteFile(session.id, 'root:/dir1/dir2');
const error = await checkThrowAsync(async () => fileController.getFile(session.id, 'root:/dir1/dir2'));
expect(error instanceof ErrorNotFound).toBe(true);
// Delete a directory and its sub-directories and files
const dir3: File = await fileController.postChild(session.id, 'root:/dir1', { name: 'dir3', is_directory: 1 });
const file1: File = await fileController.postFile_(session.id, { name: 'file1', parent_id: dir1.id });
const file2: File = await fileController.postFile_(session.id, { name: 'file2', parent_id: dir3.id });
await fileController.deleteFile(session.id, 'root:/dir1');
const fileModel = models().file({ userId: user.id });
expect(!(await fileModel.load(dir1.id))).toBe(true);
expect(!(await fileModel.load(dir3.id))).toBe(true);
expect(!(await fileModel.load(file1.id))).toBe(true);
expect(!(await fileModel.load(file2.id))).toBe(true);
});
test('should not change the parent when updating a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const dir1: File = await fileController.postChild(session.id, 'root', { name: 'dir1', is_directory: 1 });
const file1: File = await fileController.putFileContent(session.id, 'root:/dir1/myfile.md', Buffer.from('testing'));
await fileController.putFileContent(session.id, 'root:/dir1/myfile.md', Buffer.from('new content'));
const fileReloaded1 = await fileModel.load(file1.id);
expect(fileReloaded1.parent_id).toBe(dir1.id);
});
test('should not delete someone else file', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const fileController = controllers().apiFile();
const file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
await fileController.postFile_(session1.id, file1);
file2 = await fileController.postFile_(session2.id, file2);
const error = await checkThrowAsync(async () => await fileController.deleteFile(session1.id, file2.id));
expect(error instanceof ErrorForbidden).toBe(true);
});
test('should let admin change or delete files', async function() {
const { session: adminSession } = await createUserAndSession(1, true);
const { session, user } = await createUserAndSession(2);
let file: File = await makeTestFile();
const fileModel = models().file({ userId: user.id });
const fileController = controllers().apiFile();
file = await fileController.postFile_(session.id, file);
await fileController.patchFile(adminSession.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await fileController.deleteFile(adminSession.id, file.id);
expect(!(await fileModel.load(file.id))).toBe(true);
});
test('should update a file content', async function() {
const { session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(1);
const file2: File = await makeTestFile(2, 'png');
const fileController = controllers().apiFile();
const newFile = await fileController.postFile_(session.id, file);
await fileController.putFileContent(session.id, newFile.id, file2.content);
const modFile = await fileController.getFileContent(session.id, newFile.id);
const originalFileHex = (file.content as Buffer).toString('hex');
const modFileHex = (modFile.content as Buffer).toString('hex');
expect(modFileHex.length > 0).toBe(true);
expect(modFileHex === originalFileHex).toBe(false);
expect(modFile.size).toBe(modFile.content.byteLength);
expect(newFile.size).toBe(file.content.byteLength);
});
test('should delete a file content', async function() {
const { session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(1);
const fileController = controllers().apiFile();
const newFile = await fileController.postFile_(session.id, file);
await fileController.putFileContent(session.id, newFile.id, file.content);
await fileController.deleteFileContent(session.id, newFile.id);
const modFile = await fileController.getFile(session.id, newFile.id);
expect(modFile.size).toBe(0);
});
test('should not allow reserved characters', async function() {
const { session } = await createUserAndSession(1, true);
const filenames = [
'invalid*invalid',
'invalid#invalid',
'invalid\\invalid',
];
const fileController = controllers().apiFile();
for (const filename of filenames) {
const error = await checkThrowAsync(async () => fileController.putFileContent(session.id, `root:/${filename}`, null));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
}
});
test('should not allow a directory with the same name', async function() {
const { session } = await createUserAndSession(1, true);
await saveTestDir(session.id, 'root:/somedir:');
let error = await checkThrowAsync(async () => saveTestFile(session.id, 'root:/somedir:'));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
await saveTestFile(session.id, 'root:/somefile.md:');
error = await checkThrowAsync(async () => saveTestDir(session.id, 'root:/somefile.md:'));
expect(error instanceof ErrorConflict).toBe(true);
});
test('should not be possible to delete the root directory', async function() {
const { session } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const error = await checkThrowAsync(async () => fileController.deleteFile(session.id, 'root'));
expect(error instanceof ErrorForbidden).toBe(true);
});
test('should support root:/: format, which means root', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileController = controllers().apiFile();
const fileModel = models().file({ userId: user.id });
const root = await fileController.getFile(session.id, 'root:/:');
expect(root.id).toBe(await fileModel.userRootFileId());
});
test('should paginate results', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
let file3: File = await makeTestFile(3);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
await msleep(1);
file2 = await fileController.postFile_(session1.id, file2);
await msleep(1);
file3 = await fileController.postFile_(session1.id, file3);
const fileModel = models().file({ userId: user1.id });
const rootId = await fileModel.userRootFileId();
const pagination: Pagination = {
limit: 2,
order: [
{
by: 'updated_time',
dir: PaginationOrderDir.ASC,
},
],
page: 1,
};
for (const method of ['page', 'cursor']) {
const page1 = await fileController.getChildren(session1.id, rootId, pagination);
expect(page1.items.length).toBe(2);
expect(page1.has_more).toBe(true);
expect(page1.items[0].id).toBe(file1.id);
expect(page1.items[1].id).toBe(file2.id);
const p = method === 'page' ? { ...pagination, page: 2 } : { cursor: page1.cursor };
const page2 = await fileController.getChildren(session1.id, rootId, p);
expect(page2.items.length).toBe(1);
expect(page2.has_more).toBe(false);
expect(page2.items[0].id).toBe(file3.id);
}
});
test('should track file changes', async function() {
// We only do a basic check because most of the tests for this are in
// ChangeModel.test.ts
const { session: session1 } = await createUserAndSession(1);
let file1: File = await makeTestFile(1);
let file2: File = await makeTestFile(2);
const fileController = controllers().apiFile();
file1 = await fileController.postFile_(session1.id, file1);
await msleep(1); file2 = await fileController.postFile_(session1.id, file2);
const page1 = await fileController.getDelta(session1.id, file1.parent_id, { limit: 1 });
expect(page1.has_more).toBe(true);
expect(page1.items.length).toBe(1);
expect(page1.items[0].type).toBe(ChangeType.Create);
expect(page1.items[0].item.id).toBe(file1.id);
const page2 = await fileController.getDelta(session1.id, file1.parent_id, { cursor: page1.cursor, limit: 1 });
expect(page2.has_more).toBe(true);
expect(page2.items.length).toBe(1);
expect(page2.items[0].type).toBe(ChangeType.Create);
expect(page2.items[0].item.id).toBe(file2.id);
const page3 = await fileController.getDelta(session1.id, file1.parent_id, { cursor: page2.cursor, limit: 1 });
expect(page3.has_more).toBe(false);
expect(page3.items.length).toBe(0);
});
});

View File

@@ -1,101 +0,0 @@
import { File } from '../../db';
import BaseController from '../BaseController';
import { ErrorNotFound } from '../../utils/errors';
import { Pagination } from '../../models/utils/pagination';
import { PaginatedFiles } from '../../models/FileModel';
import { ChangePagination, PaginatedChanges } from '../../models/ChangeModel';
export default class FileController extends BaseController {
// Note: this is only used in tests. To create files with no content
// or directories, use postChild()
public async postFile_(sessionId: string, file: File): Promise<File> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
let newFile = fileModel.fromApiInput(file);
newFile = await fileModel.save(file);
return fileModel.toApiOutput(newFile);
}
public async getFile(sessionId: string, fileId: string): Promise<File> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const file: File = await fileModel.entityFromItemId(fileId);
const loadedFile = await fileModel.load(file.id);
if (!loadedFile) throw new ErrorNotFound();
return fileModel.toApiOutput(loadedFile);
}
public async getFileContent(sessionId: string, fileId: string): Promise<File> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
let file: File = await fileModel.entityFromItemId(fileId);
file = await fileModel.loadWithContent(file.id);
if (!file) throw new ErrorNotFound();
return file;
}
public async patchFile(sessionId: string, fileId: string, file: File): Promise<File> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const existingFile: File = await fileModel.entityFromItemId(fileId);
const newFile = fileModel.fromApiInput(file);
newFile.id = existingFile.id;
return fileModel.toApiOutput(await fileModel.save(newFile));
}
public async putFileContent(sessionId: string, fileId: string, content: Buffer): Promise<any> {
if (!content) content = Buffer.alloc(0);
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false });
file.content = content;
return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } }));
}
public async deleteFileContent(sessionId: string, fileId: string): Promise<any> {
await this.putFileContent(sessionId, fileId, null);
}
public async getChildren(sessionId: string, dirId: string, pagination: Pagination): Promise<PaginatedFiles> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const parent: File = await fileModel.entityFromItemId(dirId);
return fileModel.toApiOutput(await fileModel.childrens(parent.id, pagination));
}
public async postChild(sessionId: string, fileId: string, child: File): Promise<File> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const parent: File = await fileModel.entityFromItemId(fileId);
child = fileModel.fromApiInput(child);
child.parent_id = parent.id;
return fileModel.toApiOutput(await fileModel.save(child));
}
public async deleteFile(sessionId: string, fileId: string): Promise<void> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
try {
const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false });
if (!file.id) return;
await fileModel.delete(file.id);
} catch (error) {
if (error instanceof ErrorNotFound) {
// That's ok - a no-op
} else {
throw error;
}
}
}
public async getDelta(sessionId: string, dirId: string, pagination: ChangePagination): Promise<PaginatedChanges> {
const user = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: user.id });
const dir: File = await fileModel.entityFromItemId(dirId, { mustExist: true });
const changeModel = this.models.change({ userId: user.id });
return changeModel.byDirectoryId(dir.id, pagination);
}
}

View File

@@ -1,54 +0,0 @@
// import BaseController from '../BaseController';
// import mustacheService from '../../services/MustacheService';
// import { ErrorNotFound } from '../../utils/errors';
// import uuidgen from '../../utils/uuidgen';
// import controllers from '../factory';
// export default class OAuthController extends BaseController {
// async getAuthorize(query: any): Promise<string> {
// const clientModel = this.models.apiClient();
// const client = await clientModel.load(query.client_id);
// if (!client) throw new ErrorNotFound(`client_id missing or invalid client ID: ${query.client_id}`);
// return mustacheService.render('oauth2/authorize', {
// response_type: query.response_type,
// client: client,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// }
// async postAuthorize(query: any): Promise<string> {
// const clientModel = this.models.apiClient();
// const sessionModel = this.models.session();
// const sessionController = controllers(this.models).session();
// let client = null;
// try {
// client = await clientModel.load(query.client_id);
// if (!client) throw new ErrorNotFound(`client_id missing or invalid client ID: ${query.client_id}`);
// const session = await sessionController.authenticate(query.email, query.password);
// const authCode = uuidgen(32);
// await sessionModel.save({ id: session.id, auth_code: authCode });
// return mustacheService.render('oauth2/authcode', {
// client: client,
// authCode: authCode,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// } catch (error) {
// return mustacheService.render('oauth2/authorize', {
// response_type: query.response_type,
// client: client,
// error: error,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// }
// }
// }

View File

@@ -1,38 +0,0 @@
import { createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, controllers } from '../../utils/testing/testUtils';
import { ErrorForbidden } from '../../utils/errors';
describe('SessionController', function() {
beforeAll(async () => {
await beforeAllDb('SessionController');
});
afterAll(async () => {
await afterAllDb();
});
beforeEach(async () => {
await beforeEachDb();
});
it('should authenticate a user and give back a session', async function() {
const user = await createUser(1);
const controller = controllers().apiSession();
const session = await controller.authenticate(user.email, '123456');
expect(!!session).toBe(true);
expect(!!session.id).toBe(true);
expect(!!session.user_id).toBe(true);
});
it('should not give a session for invalid login', async function() {
const user = await createUser(1);
const controller = controllers().apiSession();
let error = await checkThrowAsync(async () => controller.authenticate(user.email, 'wrong'));
expect(error instanceof ErrorForbidden).toBe(true);
error = await checkThrowAsync(async () => controller.authenticate('wrong@wrong.com', '123456'));
expect(error instanceof ErrorForbidden).toBe(true);
});
});

View File

@@ -1,16 +0,0 @@
import { Session } from '../../db';
import { ErrorForbidden } from '../../utils/errors';
import uuidgen from '../../utils/uuidgen';
import BaseController from '../BaseController';
export default class SessionController extends BaseController {
public async authenticate(email: string, password: string): Promise<Session> {
const userModel = this.models.user();
const user = await userModel.login(email, password);
if (!user) throw new ErrorForbidden('Invalid username or password');
const session: Session = { id: uuidgen(), user_id: user.id };
return this.models.session().save(session, { isNew: true });
}
}

View File

@@ -1,192 +0,0 @@
import { models, controllers, createUserAndSession, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb } from '../../utils/testing/testUtils';
import { File, User } from '../../db';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
describe('UserController', function() {
beforeAll(async () => {
await beforeAllDb('UserController');
});
afterAll(async () => {
await afterAllDb();
});
beforeEach(async () => {
await beforeEachDb();
});
it('should create a new user along with his root file', async function() {
const { session } = await createUserAndSession(1, true);
const controller = controllers().apiUser();
const newUser = await controller.postUser(session.id, { email: 'test@example.com', password: '123456' });
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
expect(!newUser.password).toBe(true);
const userModel = models().user({ userId: newUser.id });
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === '123456').toBe(false); // Password has been hashed
const fileModel = models().file({ userId: newUser.id });
const rootFile: File = await fileModel.userRootFile();
expect(!!rootFile).toBe(true);
expect(!!rootFile.id).toBe(true);
});
it('should not create anything, neither user, root file nor permissions, if user creation fail', async function() {
const { user, session } = await createUserAndSession(1, true);
const controller = controllers().apiUser();
const fileModel = models().file({ userId: user.id });
const permissionModel = models().permission();
const userModel = models().user({ userId: user.id });
await controller.postUser(session.id, { email: 'test@example.com', password: '123456' });
const beforeFileCount = (await fileModel.all()).length;
const beforeUserCount = (await userModel.all()).length;
const beforePermissionCount = (await permissionModel.all()).length;
expect(beforeFileCount).toBe(2);
expect(beforeUserCount).toBe(2);
let hasThrown = false;
try {
await controller.postUser(session.id, { email: 'test@example.com', password: '123456' });
} catch (error) {
hasThrown = true;
}
expect(hasThrown).toBe(true);
const afterFileCount = (await fileModel.all()).length;
const afterUserCount = (await userModel.all()).length;
const afterPermissionCount = (await permissionModel.all()).length;
expect(beforeFileCount).toBe(afterFileCount);
expect(beforeUserCount).toBe(afterUserCount);
expect(beforePermissionCount).toBe(afterPermissionCount);
});
it('should change user properties', async function() {
const { user, session } = await createUserAndSession(1, true);
const controller = controllers().apiUser();
const userModel = models().user({ userId: user.id });
await controller.patchUser(session.id, { id: user.id, email: 'test2@example.com' });
let modUser: User = await userModel.load(user.id);
expect(modUser.email).toBe('test2@example.com');
const previousPassword = modUser.password;
await controller.patchUser(session.id, { id: user.id, password: 'abcdefgh' });
modUser = await userModel.load(user.id);
expect(!!modUser.password).toBe(true);
expect(modUser.password === previousPassword).toBe(false);
});
it('should get a user', async function() {
const { user, session } = await createUserAndSession();
const controller = controllers().apiUser();
const gotUser = await controller.getUser(session.id, user.id);
expect(gotUser.id).toBe(user.id);
expect(gotUser.email).toBe(user.email);
});
it('should validate user objects', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: userSession1 } = await createUserAndSession(2, false);
const { user: user2 } = await createUserAndSession(3, false);
let error = null;
const controller = controllers().apiUser();
// Non-admin user can't create a user
error = await checkThrowAsync(async () => await controller.postUser(userSession1.id, { email: 'newone@example.com', password: '1234546' }));
expect(error instanceof ErrorForbidden).toBe(true);
// Email must be set
error = await checkThrowAsync(async () => await controller.postUser(adminSession.id, { email: '', password: '1234546' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// Password must be set
error = await checkThrowAsync(async () => await controller.postUser(adminSession.id, { email: 'newone@example.com', password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// ID must be set when updating a user
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { email: 'newone@example.com' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot modify another user
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user2.id, email: 'newone@example.com' }));
expect(error instanceof ErrorForbidden).toBe(true);
// email must be set
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user1.id, email: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// password must be set
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user1.id, password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot make a user an admin
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user1.id, is_admin: 1 }));
expect(error instanceof ErrorForbidden).toBe(true);
// non-admin user cannot remove admin bit from themselves
error = await checkThrowAsync(async () => await controller.patchUser(adminSession.id, { id: admin.id, is_admin: 0 }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// there is already a user with this email
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user1.id, email: user2.email }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// check that the email is valid
error = await checkThrowAsync(async () => await controller.patchUser(userSession1.id, { id: user1.id, email: 'ohno' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
});
it('should delete a user', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);
const { user: user1, session: session1 } = await createUserAndSession(2, false);
const { user: user2, session: session2 } = await createUserAndSession(3, false);
const controller = controllers().apiUser();
const userModel = models().user({ userId: admin.id });
const allUsers: File[] = await userModel.all();
const beforeCount: number = allUsers.length;
// Can't delete someone else user
const error = await checkThrowAsync(async () => await controller.deleteUser(session1.id, user2.id));
expect(error instanceof ErrorForbidden).toBe(true);
expect((await userModel.all()).length).toBe(beforeCount);
// Admin can delete any user
await controller.deleteUser(adminSession.id, user1.id);
expect((await userModel.all()).length).toBe(beforeCount - 1);
const allFiles = await models().file().all() as File[];
expect(allFiles.length).toBe(2);
expect(!!allFiles.find(f => f.owner_id === admin.id)).toBe(true);
expect(!!allFiles.find(f => f.owner_id === user2.id)).toBe(true);
// Can delete own user
const fileModel = models().file({ userId: user2.id });
expect(!!(await fileModel.userRootFile())).toBe(true);
await controller.deleteUser(session2.id, user2.id);
expect((await userModel.all()).length).toBe(beforeCount - 2);
expect(!!(await fileModel.userRootFile())).toBe(false);
});
});

View File

@@ -1,33 +0,0 @@
import { User } from '../../db';
import BaseController from '../BaseController';
export default class UserController extends BaseController {
public async postUser(sessionId: string, user: User): Promise<User> {
const owner = await this.initSession(sessionId, true);
const userModel = this.models.user({ userId: owner.id });
let newUser = userModel.fromApiInput(user);
newUser = await userModel.save(newUser);
return userModel.toApiOutput(newUser);
}
public async getUser(sessionId: string, userId: string): Promise<User> {
const owner = await this.initSession(sessionId);
const userModel = this.models.user({ userId: owner.id });
return userModel.toApiOutput(await userModel.load(userId));
}
public async patchUser(sessionId: string, user: User): Promise<void> {
const owner = await this.initSession(sessionId);
const userModel = this.models.user({ userId: owner.id });
const newUser = userModel.fromApiInput(user);
await userModel.save(newUser, { isNew: false });
}
public async deleteUser(sessionId: string, userId: string): Promise<void> {
const user = await this.initSession(sessionId);
const userModel = this.models.user({ userId: user.id });
await userModel.delete(userId);
}
}

View File

@@ -1,65 +0,0 @@
import { Models } from '../models/factory';
import FileController from './api/FileController';
// import OAuthController from './api/OAuthController';
import SessionController from './api/SessionController';
import UserController from './api/UserController';
import IndexLoginController from './index/LoginController';
import IndexHomeController from './index/HomeController';
import IndexProfileController from './index/ProfileController';
import IndexUserController from './index/UserController';
import IndexFileController from './index/FileController';
import IndexNotificationController from './index/NotificationController';
export class Controllers {
private models_: Models;
public constructor(models: Models) {
this.models_ = models;
}
public apiFile() {
return new FileController(this.models_);
}
// public oauth() {
// return new OAuthController(this.models_);
// }
public apiSession() {
return new SessionController(this.models_);
}
public apiUser() {
return new UserController(this.models_);
}
public indexLogin() {
return new IndexLoginController(this.models_);
}
public indexHome() {
return new IndexHomeController(this.models_);
}
public indexProfile() {
return new IndexProfileController(this.models_, this.apiUser());
}
public indexUser() {
return new IndexUserController(this.models_);
}
public indexFiles() {
return new IndexFileController(this.models_);
}
public indexNotifications() {
return new IndexNotificationController(this.models_);
}
}
export default function(models: Models) {
return new Controllers(models);
}

View File

@@ -1,98 +0,0 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { Pagination, pageMaxSize, PaginationOrder, requestPaginationOrder, PaginationOrderDir, validatePagination, createPaginationLinks } from '../../models/utils/pagination';
import { File } from '../../db';
import { baseUrl } from '../../config';
import { formatDateTime } from '../../utils/time';
import { setQueryParameters } from '../../utils/urlUtils';
export function makeFilePagination(query: any): Pagination {
const limit = Number(query.limit) || pageMaxSize;
const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC);
order.splice(0, 0, { by: 'is_directory', dir: PaginationOrderDir.DESC });
const page: number = 'page' in query ? Number(query.page) : 1;
const output: Pagination = { limit, order, page };
validatePagination(output);
return output;
}
export default class FileController extends BaseController {
public async getIndex(sessionId: string, dirId: string, query: any): Promise<View> {
// Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {};
if (query.limit) baseUrlQuery.limit = query.limit;
if (query.order_by) baseUrlQuery.order_by = query.order_by;
if (query.order_dir) baseUrlQuery.order_dir = query.order_dir;
const pagination = makeFilePagination(query);
const owner = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: owner.id });
const root = await fileModel.userRootFile();
const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root;
const parent: File = await fileModel.load(parentTemp.id);
const paginatedFiles = await fileModel.childrens(parent.id, pagination);
const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit);
const parentBaseUrl = await fileModel.fileUrl(parent.id);
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
async function fileToViewItem(file: File, fileFullPaths: Record<string, string>): Promise<any> {
const filePath = fileFullPaths[file.id];
let url = `${baseUrl()}/files/${filePath}`;
if (!file.is_directory) {
url += '/content';
} else {
url = setQueryParameters(url, baseUrlQuery);
}
return {
name: file.name,
url,
type: file.is_directory ? 'directory' : 'file',
icon: file.is_directory ? 'far fa-folder' : 'far fa-file',
timestamp: formatDateTime(file.updated_time),
mime: !file.is_directory ? (file.mime_type || 'binary') : '',
};
}
const files: any[] = [];
const fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items);
if (parent.id !== root.id) {
const p = await fileModel.load(parent.parent_id);
files.push({
...await fileToViewItem(p, await fileModel.itemFullPaths([p])),
icon: 'fas fa-arrow-left',
name: '..',
});
}
for (const file of paginatedFiles.items) {
files.push(await fileToViewItem(file, fileFullPaths));
}
const view: View = defaultView('files');
view.content.paginatedFiles = { ...paginatedFiles, items: files };
view.content.paginationLinks = paginationLinks;
view.content.postUrl = `${baseUrl()}/files`;
view.content.parentId = parent.id;
view.cssFiles = ['index/files'];
view.partials.push('pagination');
return view;
}
public async deleteAll(sessionId: string, dirId: string): Promise<void> {
const owner = await this.initSession(sessionId);
const fileModel = this.models.file({ userId: owner.id });
const parent: File = await fileModel.entityFromItemId(dirId, { returnFullEntity: true });
await fileModel.deleteChildren(parent.id);
}
}

View File

@@ -1,12 +0,0 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
export default class HomeController extends BaseController {
public async getIndex(sessionId: string): Promise<View> {
await this.initSession(sessionId);
return defaultView('home');
}
}

View File

@@ -1,14 +0,0 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
export default class LoginController extends BaseController {
public async getIndex(error: any = null): Promise<View> {
const view = defaultView('login');
view.content.error = error;
view.partials = ['errorBanner'];
return view;
}
}

View File

@@ -1,23 +0,0 @@
import BaseController from '../BaseController';
import { Notification } from '../../db';
import { ErrorNotFound } from '../../utils/errors';
export default class NotificationController extends BaseController {
public async patchOne(sessionId: string, notificationId: string, notification: Notification): Promise<void> {
const owner = await this.initSession(sessionId);
const model = this.models.notification({ userId: owner.id });
const existingNotification = await model.load(notificationId);
if (!existingNotification) throw new ErrorNotFound();
console.info('aaaaaaa', notification);
const toSave: Notification = {};
if ('read' in notification) toSave.read = notification.read;
if (!Object.keys(toSave).length) return;
toSave.id = notificationId;
await model.save(toSave);
}
}

View File

@@ -1,31 +0,0 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { User } from '../../db';
import { Models } from '../../models/factory';
import UserController from '../api/UserController';
export default class ProfileController extends BaseController {
private userController_: UserController;
public constructor(models: Models, userController: UserController) {
super(models);
this.userController_ = userController;
}
public async getIndex(sessionId: string, user: User = null, error: any = null): Promise<View> {
const owner = await this.initSession(sessionId);
const view: View = defaultView('profile');
view.content.user = user ? user : owner;
view.content.error = error;
view.partials.push('errorBanner');
return view;
}
public async patchIndex(sessionId: string, user: User): Promise<void> {
await this.userController_.patchUser(sessionId, user);
}
}

View File

@@ -1,42 +0,0 @@
import BaseController from '../BaseController';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { User } from '../../db';
import { baseUrl } from '../../config';
export default class UserController extends BaseController {
public async getIndex(sessionId: string): Promise<View> {
const owner = await this.initSession(sessionId, true);
const userModel = this.models.user({ userId: owner.id });
const users = await userModel.all();
const view: View = defaultView('users');
view.content.users = users;
return view;
}
public async getOne(sessionId: string, isNew: boolean, userIdOrString: string | User = null, error: any = null): Promise<View> {
const owner = await this.initSession(sessionId);
const userModel = this.models.user({ userId: owner.id });
let user: User = {};
if (typeof userIdOrString === 'string') {
user = await userModel.load(userIdOrString as string);
} else {
user = userIdOrString as User;
}
const view: View = defaultView('user');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.error = error;
view.content.postUrl = `${baseUrl()}/users${isNew ? '/new' : `/${user.id}`}`;
view.partials.push('errorBanner');
return view;
}
}

View File

@@ -47,15 +47,15 @@ export interface ConnectionCheckResult {
connection: DbConnection;
}
export function sqliteFilePath(dbConfig: DatabaseConfig): string {
return `${sqliteDbDir}/db-${dbConfig.name}.sqlite`;
export function sqliteFilePath(name: string): string {
return `${sqliteDbDir}/db-${name}.sqlite`;
}
export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig {
const connection: DbConfigConnection = {};
if (dbConfig.client === 'sqlite3') {
connection.filename = sqliteFilePath(dbConfig);
connection.filename = sqliteFilePath(dbConfig.name);
} else {
connection.database = dbConfig.name;
connection.host = dbConfig.host;

View File

@@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { defaultAdminEmail, defaultAdminPassword, Notification } from '../db';
import notificationHandler from './notificationHandler';
@@ -9,7 +9,7 @@ describe('notificationHandler', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {
@@ -17,7 +17,7 @@ describe('notificationHandler', function() {
});
test('should check admin password', async function() {
const { user } = await createUserAndSession(1, true);
const { user, session } = await createUserAndSession(1, true);
const admin = await models().user({ userId: user.id }).save({
email: defaultAdminEmail,
@@ -26,7 +26,7 @@ describe('notificationHandler', function() {
});
{
const context = await koaAppContext({ owner: user });
const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
const notifications: Notification[] = await models().notification().all();
@@ -43,7 +43,7 @@ describe('notificationHandler', function() {
password: 'changed!',
});
const context = await koaAppContext({ owner: user });
const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
const notifications: Notification[] = await models().notification().all();
@@ -56,14 +56,14 @@ describe('notificationHandler', function() {
});
test('should not check admin password for non-admin', async function() {
const { user } = await createUserAndSession(1, false);
const { session } = await createUserAndSession(1, false);
await createUserAndSession(2, true, {
email: defaultAdminEmail,
password: defaultAdminPassword,
});
const context = await koaAppContext({ owner: user });
const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
const notifications: Notification[] = await models().notification().all();

View File

@@ -4,47 +4,80 @@ import { defaultAdminEmail, defaultAdminPassword, NotificationLevel } from '../d
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
import * as MarkdownIt from 'markdown-it';
import config from '../config';
const logger = Logger.create('notificationHandler');
async function handleChangeAdminPasswordNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return;
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
if (defaultAdmin) {
await notificationModel.add(
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead('change_admin_password');
}
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
);
}
}
async function handleSqliteInProdNotification(ctx: AppContext) {
if (!ctx.owner.is_admin) return;
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
);
}
}
async function makeNotificationViews(ctx: AppContext): Promise<NotificationView[]> {
const markdownIt = new MarkdownIt();
const notificationModel = ctx.models.notification({ userId: ctx.owner.id });
const notifications = await notificationModel.allUnreadByUserId(ctx.owner.id);
const views: NotificationView[] = [];
for (const n of notifications) {
views.push({
id: n.id,
messageHtml: markdownIt.render(n.message),
level: n.level === NotificationLevel.Important ? 'warning' : 'info',
closeUrl: notificationModel.closeUrl(n.id),
});
}
return views;
}
// The role of this middleware is to inspect the system and to generate
// notifications for any issue it finds. It is only active for logged in users
// on the website. It is inactive for API calls.
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
ctx.notifications = [];
try {
if (isApiRequest(ctx)) return next();
if (!ctx.owner) return next();
const user = ctx.owner;
if (!user) return next();
const notificationModel = ctx.models.notification({ userId: user.id });
if (user.is_admin) {
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
if (defaultAdmin) {
await notificationModel.add(
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead('change_admin_password');
}
}
const markdownIt = new MarkdownIt();
const notifications = await notificationModel.allUnreadByUserId(user.id);
const views: NotificationView[] = [];
for (const n of notifications) {
views.push({
id: n.id,
messageHtml: markdownIt.render(n.message),
level: n.level === NotificationLevel.Important ? 'warning' : 'info',
closeUrl: notificationModel.closeUrl(n.id),
});
}
ctx.notifications = views;
await handleChangeAdminPasswordNotification(ctx);
await handleSqliteInProdNotification(ctx);
ctx.notifications = await makeNotificationViews(ctx);
} catch (error) {
logger.error(error);
}

View File

@@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, koaNext } from '../utils/testing/testUtils';
import ownerHandler from './ownerHandler';
describe('ownerHandler', function() {
@@ -8,7 +8,7 @@ describe('ownerHandler', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {
@@ -22,7 +22,7 @@ describe('ownerHandler', function() {
sessionId: session.id,
});
expect(!!context.owner).toBe(false);
context.owner = null;
await ownerHandler(context, koaNext);
@@ -37,7 +37,7 @@ describe('ownerHandler', function() {
sessionId: 'ihack',
});
expect(!!context.owner).toBe(false);
context.owner = null;
await ownerHandler(context, koaNext);

View File

@@ -1,17 +1,8 @@
import { AppContext, KoaNext } from '../utils/types';
import { isApiRequest, contextSessionId } from '../utils/requestUtils';
import Logger from '@joplin/lib/Logger';
const logger = Logger.create('ownerHandler');
import { contextSessionId } from '../utils/requestUtils';
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
try {
if (isApiRequest(ctx)) return next();
const sessionId = contextSessionId(ctx);
ctx.owner = await ctx.models.session().sessionUser(sessionId);
} catch (error) {
logger.error(error);
}
const sessionId = contextSessionId(ctx, false);
if (sessionId) ctx.owner = await ctx.models.session().sessionUser(sessionId);
return next();
}

View File

@@ -1,7 +1,7 @@
import routes from '../routes/routes';
import { ErrorNotFound } from '../utils/errors';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import { AppContext, Env, HttpMethod } from '../utils/types';
import mustacheService, { isView, View } from '../services/MustacheService';
export default async function(ctx: AppContext) {
@@ -13,7 +13,12 @@ export default async function(ctx: AppContext) {
const match = findMatchingRoute(ctx.path, routes);
if (match) {
const responseObject = await match.route.exec(match.subPath, ctx);
let responseObject = null;
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
responseObject = await routeHandler(match.subPath, ctx);
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
@@ -21,6 +26,7 @@ export default async function(ctx: AppContext) {
ctx.response.status = 200;
ctx.response.body = await mustacheService.renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
});
} else {
@@ -39,7 +45,7 @@ export default async function(ctx: AppContext) {
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx.path);
const responseFormat = routeResponseFormat(match, ctx);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');

View File

@@ -102,16 +102,57 @@ export default abstract class BaseModel {
return false;
}
protected async withTransaction(fn: Function): Promise<void> {
// When using withTransaction, make sure any database call uses an instance
// of `this.db()` that was accessed within the `fn` callback, otherwise the
// transaction will be stuck!
//
// This for example, would result in a stuck transaction:
//
// const query = this.db(this.tableName).where('id', '=', id);
//
// this.withTransaction(async () => {
// await query.delete();
// });
//
// This is because withTransaction is going to swap the value of "this.db()"
// for as long as the transaction is active. So if the query is started
// outside the transaction, it will use the regular db connection and wait
// for the newly created transaction to finish, which will never happen.
//
// This is a bit of a leaky abstraction, which ideally should be improved
// but for now one just has to be aware of the caveat.
//
// The `name` argument is only for debugging, so that any stuck transaction
// can be more easily identified.
protected async withTransaction(fn: Function, name: string = null): Promise<void> {
const debugTransaction = false;
const debugTimerId = debugTransaction ? setTimeout(() => {
console.info('Transaction did not complete:', name, txIndex);
}, 5000) : null;
const txIndex = await this.transactionHandler_.start();
if (debugTransaction) console.info('START', name, txIndex);
try {
await fn();
} catch (error) {
await this.transactionHandler_.rollback(txIndex);
if (debugTransaction) {
console.info('ROLLBACK', name, txIndex);
clearTimeout(debugTimerId);
}
throw error;
}
if (debugTransaction) {
console.info('COMMIT', name, txIndex);
clearTimeout(debugTimerId);
}
await this.transactionHandler_.commit(txIndex);
}
@@ -197,7 +238,7 @@ export default abstract class BaseModel {
// Sanity check:
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
}
});
}, 'BaseModel::save');
return toSave;
}
@@ -220,11 +261,6 @@ export default abstract class BaseModel {
if (!ids.length) throw new Error('no id provided');
const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) {
await query.orWhere({ id: ids[i] });
}
const trackChanges = this.trackChanges;
let itemsWithParentIds: AnyItemType[] = null;
@@ -233,13 +269,18 @@ export default abstract class BaseModel {
}
await this.withTransaction(async () => {
const query = this.db(this.tableName).where({ id: ids[0] });
for (let i = 1; i < ids.length; i++) {
await query.orWhere({ id: ids[i] });
}
const deletedCount = await query.del();
if (deletedCount !== ids.length) throw new Error(`${ids.length} row(s) should have been deleted by ${deletedCount} row(s) were deleted`);
if (trackChanges) {
for (const item of itemsWithParentIds) await this.handleChangeTracking({}, item, ChangeType.Delete);
}
});
}, 'BaseModel::delete');
}
}

View File

@@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { ChangeType, File } from '../db';
import FileModel from './FileModel';
import { msleep } from '../utils/time';
@@ -18,7 +18,7 @@ describe('ChangeModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFileTree } from '../utils/testing/testUtils';
import { File } from '../db';
describe('FileModel', function() {
@@ -8,7 +8,7 @@ describe('FileModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@@ -87,7 +87,7 @@ export default class FileModel extends BaseModel {
output[item.id] = segments.length ? (`root:/${segments.join('/')}:`) : 'root';
}
});
}, 'FileModel::itemFullPaths');
return output;
}
@@ -404,7 +404,7 @@ export default class FileModel extends BaseModel {
for (const childId of childrenIds) {
await this.delete(childId);
}
});
}, 'FileModel::deleteChildren');
}
public async delete(id: string, options: DeleteOptions = {}): Promise<void> {
@@ -427,7 +427,7 @@ export default class FileModel extends BaseModel {
}
await super.delete(id);
});
}, 'FileModel::delete');
}
}

View File

@@ -1,4 +1,4 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { Notification, NotificationLevel } from '../db';
describe('NotificationModel', function() {
@@ -8,7 +8,7 @@ describe('NotificationModel', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@@ -1,5 +1,7 @@
import BaseModel from './BaseModel';
import { User, Session } from '../db';
import uuidgen from '../utils/uuidgen';
import { ErrorForbidden } from '../utils/errors';
export default class SessionModel extends BaseModel {
@@ -14,4 +16,22 @@ export default class SessionModel extends BaseModel {
return userModel.load(session.user_id);
}
public async createUserSession(userId: string): Promise<Session> {
return this.save({
id: uuidgen(),
user_id: userId,
}, { isNew: true });
}
public async authenticate(email: string, password: string): Promise<Session> {
const user = await this.models().user().login(email, password);
if (!user) throw new ErrorForbidden('Invalid username or password');
return this.createUserSession(user.id);
}
public async logout(sessionId: string) {
if (!sessionId) return;
await this.delete(sessionId);
}
}

View File

@@ -0,0 +1,98 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
import { File } from '../db';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
describe('UserModel', function() {
beforeAll(async () => {
await beforeAllDb('UserModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should validate user objects', async function() {
const { user: admin } = await createUserAndSession(1, true);
const { user: user1 } = await createUserAndSession(2, false);
const { user: user2 } = await createUserAndSession(3, false);
let error = null;
// Non-admin user can't create a user
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ email: 'newone@example.com', password: '1234546' }));
expect(error instanceof ErrorForbidden).toBe(true);
// Email must be set
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ email: '', password: '1234546' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// Password must be set
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ email: 'newone@example.com', password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot modify another user
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user2.id, email: 'newone@example.com' }));
expect(error instanceof ErrorForbidden).toBe(true);
// email must be set
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// password must be set
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, password: '' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// non-admin user cannot make a user an admin
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, is_admin: 1 }));
expect(error instanceof ErrorForbidden).toBe(true);
// non-admin user cannot remove admin bit from themselves
error = await checkThrowAsync(async () => await models().user({ userId: admin.id }).save({ id: admin.id, is_admin: 0 }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// there is already a user with this email
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: user2.email }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
// check that the email is valid
error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).save({ id: user1.id, email: 'ohno' }));
expect(error instanceof ErrorUnprocessableEntity).toBe(true);
});
test('should delete a user', async function() {
const { user: admin } = await createUserAndSession(1, true);
const { user: user1 } = await createUserAndSession(2, false);
const { user: user2 } = await createUserAndSession(3, false);
const userModel = models().user({ userId: admin.id });
const allUsers: File[] = await userModel.all();
const beforeCount: number = allUsers.length;
// Can't delete someone else user
const error = await checkThrowAsync(async () => await models().user({ userId: user1.id }).delete(user2.id));
expect(error instanceof ErrorForbidden).toBe(true);
expect((await userModel.all()).length).toBe(beforeCount);
// Admin can delete any user
await models().user({ userId: admin.id }).delete(user1.id);
expect((await userModel.all()).length).toBe(beforeCount - 1);
const allFiles = await models().file().all() as File[];
expect(allFiles.length).toBe(2);
expect(!!allFiles.find(f => f.owner_id === admin.id)).toBe(true);
expect(!!allFiles.find(f => f.owner_id === user2.id)).toBe(true);
// Can delete own user
const fileModel = models().file({ userId: user2.id });
expect(!!(await fileModel.userRootFile())).toBe(true);
await models().user({ userId: user2.id }).delete(user2.id);
expect((await userModel.all()).length).toBe(beforeCount - 2);
expect(!!(await fileModel.userRootFile())).toBe(false);
});
});

View File

@@ -72,7 +72,7 @@ export default class UserModel extends BaseModel {
}
public async profileUrl(): Promise<string> {
return `${this.baseUrl}/profile`;
return `${this.baseUrl}/users/me`;
}
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
@@ -97,7 +97,7 @@ export default class UserModel extends BaseModel {
const rootFile = await fileModel.userRootFile();
await fileModel.delete(rootFile.id, { validationRules: { canDeleteRoot: true } });
await super.delete(id);
});
}, 'UserModel::delete');
}
public async save(object: User, options: SaveOptions = {}): Promise<User> {
@@ -114,7 +114,7 @@ export default class UserModel extends BaseModel {
const fileModel = this.models().file({ userId: newUser.id });
await fileModel.createRootFile();
}
});
}, 'UserModel::save');
return newUser;
}

View File

@@ -20,6 +20,15 @@ export interface Pagination {
cursor?: string;
}
interface PaginationQueryParams {
limit?: number;
order_by?: string;
order_dir?: string;
page?: number;
cursor?: string;
}
export interface PaginatedResults {
items: any[];
has_more: boolean;
@@ -107,6 +116,25 @@ export function requestChangePagination(query: any): ChangePagination {
return output;
}
export function paginationToQueryParams(pagination: Pagination): PaginationQueryParams {
const output: PaginationQueryParams = {};
if (!pagination) return {};
if ('limit' in pagination) output.limit = pagination.limit;
if ('page' in pagination) output.page = pagination.page;
if ('cursor' in pagination) output.cursor = pagination.cursor;
if ('order' in pagination) {
const o = pagination.order;
if (o.length) {
output.order_by = o[0].by;
output.order_dir = o[0].dir;
}
}
return output;
}
export interface PageLink {
page?: number;
isEllipsis?: boolean;

View File

@@ -1,12 +1,36 @@
import routeHandler from '../../middleware/routeHandler';
import { testAssetDir, beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
import { testAssetDir, beforeAllDb, randomHash, afterAllTests, beforeEachDb, createUserAndSession, models, tempDir } from '../../utils/testing/testUtils';
import { getFileMetadataContext, getFileMetadata, deleteFileContent, deleteFileContext, deleteFile, postDirectoryContext, postDirectory, getDirectoryChildren, putFileContentContext, putFileContent, getFileContent, patchFileContext, patchFile, getDelta } from '../../utils/testing/apiUtils';
import * as fs from 'fs-extra';
import { ChangeType, File } from '../../db';
import { Pagination, PaginationOrderDir } from '../../models/utils/pagination';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorNotFound, ErrorConflict } from '../../utils/errors';
import { msleep } from '../../utils/time';
function testFilePath(ext: string = 'jpg') {
const basename = ext === 'jpg' ? 'photo' : 'poster';
return `${testAssetDir}/${basename}.${ext}`;
}
async function makeTempFileWithContent(content: string): Promise<string> {
const d = await tempDir();
const filePath = `${d}/${randomHash()}`;
await fs.writeFile(filePath, content, 'utf8');
return filePath;
}
async function makeTestFile(ownerId: string, id: number = 1, ext: string = 'jpg', parentId: string = ''): Promise<File> {
const basename = ext === 'jpg' ? 'photo' : 'poster';
const file: File = {
name: id > 1 ? `${basename}-${id}.${ext}` : `${basename}.${ext}`,
content: await fs.readFile(`${testAssetDir}/${basename}.${ext}`),
// mime_type: `image/${ext}`,
parent_id: parentId,
};
return models().file({ userId: ownerId }).save(file);
}
describe('api_files', function() {
beforeAll(async () => {
@@ -14,7 +38,7 @@ describe('api_files', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {
@@ -25,18 +49,7 @@ describe('api_files', function() {
const { user, session } = await createUserAndSession(1, true);
const filePath = testFilePath();
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'PUT',
url: '/api/files/root:/photo.jpg:/content',
files: { file: { path: filePath } },
},
});
await routeHandler(context);
const newFile = context.response.body;
const newFile = await putFileContent(session.id, 'root:/photo.jpg:', filePath);
expect(!!newFile.id).toBe(true);
expect(newFile.name).toBe('photo.jpg');
@@ -60,53 +73,347 @@ describe('api_files', function() {
test('should create sub-directories', async function() {
const { session } = await createUserAndSession(1, true);
const context1 = await koaAppContext({
sessionId: session.id,
request: {
method: 'POST',
url: '/api/files/root/children',
body: {
is_directory: 1,
name: 'subdir',
},
},
});
await routeHandler(context1);
const newDir = context1.response.body;
const newDir = await postDirectory(session.id, 'root:/:', 'subdir');
expect(!!newDir.id).toBe(true);
expect(newDir.is_directory).toBe(1);
const context2 = await koaAppContext({
sessionId: session.id,
request: {
method: 'POST',
url: '/api/files/root:/subdir:/children',
body: {
is_directory: 1,
name: 'subdir2',
},
},
});
const newDir2 = await postDirectory(session.id, 'root:/subdir:', 'subdir2');
await routeHandler(context2);
const newDir2 = context2.response.body;
const context3 = await koaAppContext({
sessionId: session.id,
request: {
method: 'GET',
url: '/api/files/root:/subdir/subdir2:',
},
});
await routeHandler(context3);
const newDirReload2 = context3.response.body;
const newDirReload2 = await getFileMetadata(session.id, 'root:/subdir/subdir2:');
expect(newDirReload2.id).toBe(newDir2.id);
expect(newDirReload2.name).toBe(newDir2.name);
});
test('should create files in sub-directory', async function() {
const { session } = await createUserAndSession(1, true);
await postDirectory(session.id, 'root:/:', 'subdir');
const newFile = await putFileContent(session.id, 'root:/subdir/photo.jpg:', testFilePath());
const newFileReload = await getFileMetadata(session.id, 'root:/subdir/photo.jpg:');
expect(newFileReload.id).toBe(newFile.id);
expect(newFileReload.name).toBe('photo.jpg');
});
test('should not create a file with an invalid path', async function() {
const { session } = await createUserAndSession(1, true);
const context = await putFileContentContext(session.id, 'root:/does/not/exist/photo.jpg:', testFilePath());
expect(context.response.status).toBe(ErrorNotFound.httpCode);
});
test('should get files', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
let file1: File = await makeTestFile(user1.id, 1);
const file2: File = await makeTestFile(user1.id, 2);
const file3: File = await makeTestFile(user2.id, 3);
const fileId1 = file1.id;
const fileId2 = file2.id;
// Can't get someone else file
const context = await getFileMetadataContext(session1.id, file3.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
file1 = await getFileMetadata(session1.id, file1.id);
expect(file1.id).toBe(fileId1);
const fileModel = models().file({ userId: user1.id });
const paginatedResults = await getDirectoryChildren(session1.id, await fileModel.userRootFileId());
const allFiles: File[] = paginatedResults.items;
expect(allFiles.length).toBe(2);
expect(JSON.stringify(allFiles.map(f => f.id).sort())).toBe(JSON.stringify([fileId1, fileId2].sort()));
});
test('should not let create a file in a directory not owned by user', async function() {
const { session: session1 } = await createUserAndSession(1);
const { session: session2 } = await createUserAndSession(2);
const file = await putFileContent(session2.id, 'root:/test.jpg:', testFilePath());
const context = await getFileMetadataContext(session1.id, file.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should update file properties', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
let file = await putFileContent(session.id, 'root:/test.jpg:', testFilePath());
// Can't have file with empty name
const context = await patchFileContext(session.id, file.id, { name: '' });
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
await patchFile(session.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await patchFile(session.id, file.id, { mime_type: 'image/png' });
file = await fileModel.load(file.id);
expect(file.mime_type).toBe('image/png');
});
test('should not allow duplicate filenames', async function() {
const { session } = await createUserAndSession(1, true);
const c1 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c1.response.status).toBe(200);
const c2 = await postDirectoryContext(session.id, 'root:/:', 'mydir');
expect(c2.response.status).toBe(ErrorConflict.httpCode);
});
test('should change the file parent', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const fileModel = models().file({ userId: user1.id });
let file: File = await makeTestFile(user1.id);
const file2: File = await makeTestFile(user1.id, 2);
const dir: File = await postDirectory(session1.id, 'root', 'mydir');
// Can't set parent to another non-directory file
const context1 = await patchFileContext(session1.id, file.id, { parent_id: file2.id });
expect(context1.response.status).toBe(ErrorForbidden.httpCode);
// Can't set parent to someone else directory
const fileModel2 = models().file({ userId: user2.id });
const userRoot2 = await fileModel2.userRootFile();
const context2 = await patchFileContext(session1.id, file.id, { parent_id: userRoot2.id });
expect(context2.response.status).toBe(ErrorForbidden.httpCode);
// Finally, change the parent
await patchFile(session1.id, file.id, { parent_id: dir.id });
file = await fileModel.load(file.id);
expect(!!file.parent_id).toBe(true);
expect(file.parent_id).toBe(dir.id);
});
test('should delete a file', async function() {
const { user, session } = await createUserAndSession(1, true);
await makeTestFile(user.id, 1);
const file2: File = await makeTestFile(user.id, 2);
const fileModel = models().file({ userId: user.id });
let allFiles: File[] = await fileModel.all();
const beforeCount: number = allFiles.length;
await deleteFile(session.id, file2.id);
allFiles = await fileModel.all();
expect(allFiles.length).toBe(beforeCount - 1);
});
test('should create and delete directories', async function() {
const { user, session } = await createUserAndSession(1, true);
const dir1 = await postDirectory(session.id, 'root', 'dir1');
const dir2 = await postDirectory(session.id, dir1.id, 'dir2');
const dirReload2: File = await getFileMetadata(session.id, 'root:/dir1/dir2:');
expect(dirReload2.id).toBe(dir2.id);
// Delete one directory
await deleteFile(session.id, 'root:/dir1/dir2:');
const dirNotFoundContext = await getFileMetadataContext(session.id, 'root:/dir1/dir2:');
expect(dirNotFoundContext.response.status).toBe(ErrorNotFound.httpCode);
// Delete a directory and its sub-directories and files
const dir3 = await postDirectory(session.id, 'root:/dir1:', 'dir3');
const file1 = await putFileContent(session.id, 'root:/dir1/file1:', testFilePath());
const file2 = await putFileContent(session.id, 'root:/dir1/dir3/file2:', testFilePath());
await deleteFile(session.id, 'root:/dir1:');
const fileModel = models().file({ userId: user.id });
expect(!(await fileModel.load(dir1.id))).toBe(true);
expect(!(await fileModel.load(dir3.id))).toBe(true);
expect(!(await fileModel.load(file1.id))).toBe(true);
expect(!(await fileModel.load(file2.id))).toBe(true);
});
test('should not change the parent when updating a file', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const dir1: File = await postDirectory(session.id, 'root', 'dir1');
const file1: File = await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('testing'));
await putFileContent(session.id, 'root:/dir1/myfile.md:', await makeTempFileWithContent('new content'));
const fileReloaded1 = await fileModel.load(file1.id);
expect(fileReloaded1.parent_id).toBe(dir1.id);
});
test('should not delete someone else file', async function() {
const { session: session1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
const file2: File = await makeTestFile(user2.id, 2);
const context = await deleteFileContext(session1.id, file2.id);
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should let admin change or delete files', async function() {
const { session: adminSession } = await createUserAndSession(1, true);
const { user } = await createUserAndSession(2);
let file: File = await makeTestFile(user.id);
const fileModel = models().file({ userId: user.id });
await patchFile(adminSession.id, file.id, { name: 'modified.jpg' });
file = await fileModel.load(file.id);
expect(file.name).toBe('modified.jpg');
await deleteFile(adminSession.id, file.id);
expect(!(await fileModel.load(file.id))).toBe(true);
});
test('should update a file content', async function() {
const { session } = await createUserAndSession(1, true);
const contentPath1 = await makeTempFileWithContent('test1');
const contentPath2 = await makeTempFileWithContent('test2');
await putFileContent(session.id, 'root:/file.txt:', contentPath1);
const originalContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(originalContent).toBe('test1');
await putFileContent(session.id, 'root:/file.txt:', contentPath2);
const modContent = (await getFileContent(session.id, 'root:/file.txt:')).toString();
expect(modContent).toBe('test2');
});
test('should delete a file content', async function() {
const { user, session } = await createUserAndSession(1, true);
const file: File = await makeTestFile(user.id, 1);
await putFileContent(session.id, file.id, await makeTempFileWithContent('test1'));
await deleteFileContent(session.id, file.id);
const modFile = await getFileMetadata(session.id, file.id);
expect(modFile.size).toBe(0);
});
test('should not allow reserved characters', async function() {
const { session } = await createUserAndSession(1, true);
const filenames = [
'invalid*invalid',
'invalid#invalid',
'invalid\\invalid',
];
for (const filename of filenames) {
const context = await putFileContentContext(session.id, `root:/${filename}:`, testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
});
test('should not allow a directory with the same name', async function() {
const { session } = await createUserAndSession(1, true);
{
await postDirectory(session.id, 'root', 'somedir');
const context = await putFileContentContext(session.id, 'root:/somedir:', testFilePath());
expect(context.response.status).toBe(ErrorUnprocessableEntity.httpCode);
}
{
await putFileContent(session.id, 'root:/somefile.md:', testFilePath());
const context = await postDirectoryContext(session.id, 'root', 'somefile.md');
expect(context.response.status).toBe(ErrorConflict.httpCode);
}
});
test('should not be possible to delete the root directory', async function() {
const { session } = await createUserAndSession(1, true);
const context = await deleteFileContext(session.id, 'root');
expect(context.response.status).toBe(ErrorForbidden.httpCode);
});
test('should support root:/: format, which means root', async function() {
const { session, user } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const root = await getFileMetadata(session.id, 'root:/:');
expect(root.id).toBe(await fileModel.userRootFileId());
});
test('should paginate results', async function() {
const { session: session1, user: user1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user1.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user1.id, 2);
await msleep(1);
const file3: File = await makeTestFile(user1.id, 3);
const fileModel = models().file({ userId: user1.id });
const rootId = await fileModel.userRootFileId();
const pagination: Pagination = {
limit: 2,
order: [
{
by: 'updated_time',
dir: PaginationOrderDir.ASC,
},
],
page: 1,
};
for (const method of ['page', 'cursor']) {
const page1 = await getDirectoryChildren(session1.id, rootId, pagination);
expect(page1.items.length).toBe(2);
expect(page1.has_more).toBe(true);
expect(page1.items[0].id).toBe(file1.id);
expect(page1.items[1].id).toBe(file2.id);
const p = method === 'page' ? { ...pagination, page: 2 } : { cursor: page1.cursor };
const page2 = await getDirectoryChildren(session1.id, rootId, p);
expect(page2.items.length).toBe(1);
expect(page2.has_more).toBe(false);
expect(page2.items[0].id).toBe(file3.id);
}
});
test('should track file changes', async function() {
// We only do a basic check because most of the tests for this are in
// ChangeModel.test.ts
const { user, session: session1 } = await createUserAndSession(1);
const file1: File = await makeTestFile(user.id, 1);
await msleep(1);
const file2: File = await makeTestFile(user.id, 2);
const page1 = await getDelta(session1.id, file1.parent_id, { limit: 1 });
expect(page1.has_more).toBe(true);
expect(page1.items.length).toBe(1);
expect(page1.items[0].type).toBe(ChangeType.Create);
expect(page1.items[0].item.id).toBe(file1.id);
const page2 = await getDelta(session1.id, file1.parent_id, { cursor: page1.cursor, limit: 1 });
expect(page2.has_more).toBe(true);
expect(page2.items.length).toBe(1);
expect(page2.items[0].type).toBe(ChangeType.Create);
expect(page2.items[0].item.id).toBe(file2.id);
const page3 = await getDelta(session1.id, file1.parent_id, { cursor: page2.cursor, limit: 1 });
expect(page3.has_more).toBe(false);
expect(page3.items.length).toBe(0);
});
});

View File

@@ -1,81 +1,98 @@
import { ErrorNotFound, ErrorMethodNotAllowed, ErrorBadRequest } from '../../utils/errors';
import { ErrorNotFound, ErrorBadRequest } from '../../utils/errors';
import { File } from '../../db';
import { bodyFields, formParse, headerSessionId } from '../../utils/requestUtils';
import { SubPath, Route, respondWithFileContent } from '../../utils/routeUtils';
import { bodyFields, formParse } from '../../utils/requestUtils';
import { SubPath, respondWithFileContent } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import * as fs from 'fs-extra';
import { requestChangePagination, requestPagination } from '../../models/utils/pagination';
const route: Route = {
const router = new Router();
exec: async function(path: SubPath, ctx: AppContext) {
const fileController = ctx.controllers.apiFile();
router.get('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const file: File = await fileModel.entityFromItemId(fileId);
const loadedFile = await fileModel.load(file.id);
if (!loadedFile) throw new ErrorNotFound();
return fileModel.toApiOutput(loadedFile);
});
// console.info(`${ctx.method} ${path.id}${path.link ? `/${path.link}` : ''}`);
router.patch('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const inputFile: File = await bodyFields(ctx.req);
const existingFile: File = await fileModel.entityFromItemId(fileId);
const newFile = fileModel.fromApiInput(inputFile);
newFile.id = existingFile.id;
return fileModel.toApiOutput(await fileModel.save(newFile));
});
if (!path.link) {
if (ctx.method === 'GET') {
return fileController.getFile(headerSessionId(ctx.headers), path.id);
}
if (ctx.method === 'PATCH') {
return fileController.patchFile(headerSessionId(ctx.headers), path.id, await bodyFields(ctx.req));
}
if (ctx.method === 'DELETE') {
return fileController.deleteFile(headerSessionId(ctx.headers), path.id);
}
throw new ErrorMethodNotAllowed();
router.del('api/files/:id', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
try {
const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false });
if (!file.id) return;
await fileModel.delete(file.id);
} catch (error) {
if (error instanceof ErrorNotFound) {
// That's ok - a no-op
} else {
throw error;
}
}
});
if (path.link === 'content') {
if (ctx.method === 'GET') {
const file: File = await fileController.getFileContent(headerSessionId(ctx.headers), path.id);
return respondWithFileContent(ctx.response, file);
}
router.get('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
let file: File = await fileModel.entityFromItemId(fileId);
file = await fileModel.loadWithContent(file.id);
if (!file) throw new ErrorNotFound();
return respondWithFileContent(ctx.response, file);
});
if (ctx.method === 'PUT') {
const result = await formParse(ctx.req);
if (!result?.files?.file) throw new ErrorBadRequest('File data is missing');
const buffer = await fs.readFile(result.files.file.path);
return fileController.putFileContent(headerSessionId(ctx.headers), path.id, buffer);
}
router.put('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const result = await formParse(ctx.req);
if (!result?.files?.file) throw new ErrorBadRequest('File data is missing');
const buffer = await fs.readFile(result.files.file.path);
if (ctx.method === 'DELETE') {
return fileController.deleteFileContent(headerSessionId(ctx.headers), path.id);
}
const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false });
file.content = buffer;
return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } }));
});
throw new ErrorMethodNotAllowed();
}
router.del('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const fileId = path.id;
const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false });
if (!file) return;
file.content = Buffer.alloc(0);
await fileModel.save(file, { validationRules: { mustBeFile: true } });
});
if (path.link === 'delta') {
if (ctx.method === 'GET') {
return fileController.getDelta(
headerSessionId(ctx.headers),
path.id,
requestChangePagination(ctx.query)
);
}
router.get('api/files/:id/delta', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const dir: File = await fileModel.entityFromItemId(path.id, { mustExist: true });
const changeModel = ctx.models.change({ userId: ctx.owner.id });
return changeModel.byDirectoryId(dir.id, requestChangePagination(ctx.query));
});
throw new ErrorMethodNotAllowed();
}
router.get('api/files/:id/children', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const parent: File = await fileModel.entityFromItemId(path.id);
return fileModel.toApiOutput(await fileModel.childrens(parent.id, requestPagination(ctx.query)));
});
if (path.link === 'children') {
if (ctx.method === 'GET') {
return fileController.getChildren(headerSessionId(ctx.headers), path.id, requestPagination(ctx.query));
}
router.post('api/files/:id/children', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const child: File = fileModel.fromApiInput(await bodyFields(ctx.req));
const parent: File = await fileModel.entityFromItemId(path.id);
child.parent_id = parent.id;
return fileModel.toApiOutput(await fileModel.save(child));
});
if (ctx.method === 'POST') {
return fileController.postChild(headerSessionId(ctx.headers), path.id, await bodyFields(ctx.req));
}
throw new ErrorMethodNotAllowed();
}
throw new ErrorNotFound(`Invalid link: ${path.link}`);
},
};
export default route;
export default router;

View File

@@ -1,11 +0,0 @@
import { Route } from '../../utils/routeUtils';
const route: Route = {
exec: async function() {
return { status: 'ok', message: 'Joplin Server is running' };
},
};
export default route;

View File

@@ -1,5 +1,5 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllDb, beforeEachDb, koaAppContext } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext } from '../../utils/testing/testUtils';
describe('api_ping', function() {
@@ -8,7 +8,7 @@ describe('api_ping', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {

View File

@@ -1,11 +1,11 @@
import { Route } from '../../utils/routeUtils';
import Router from '../../utils/Router';
const route: Route = {
const router = new Router();
exec: async function() {
return { status: 'ok', message: 'Joplin Server is running' };
},
router.public = true;
};
router.get('api/ping', async () => {
return { status: 'ok', message: 'Joplin Server is running' };
});
export default route;
export default router;

View File

@@ -1,6 +1,24 @@
import { Session } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllDb, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models } from '../../utils/testing/testUtils';
import { AppContext } from '../../utils/types';
async function postSession(email: string, password: string): Promise<AppContext> {
const context = await koaAppContext({
request: {
method: 'POST',
url: '/api/sessions',
body: {
email: email,
password: password,
},
},
});
await routeHandler(context);
return context;
}
describe('api_sessions', function() {
@@ -9,7 +27,7 @@ describe('api_sessions', function() {
});
afterAll(async () => {
await afterAllDb();
await afterAllTests();
});
beforeEach(async () => {
@@ -19,19 +37,7 @@ describe('api_sessions', function() {
test('should login user', async function() {
const { user } = await createUserAndSession(1, false);
const context = await koaAppContext({
request: {
method: 'POST',
url: '/api/sessions',
body: {
email: user.email,
password: '123456',
},
},
});
await routeHandler(context);
const context = await postSession(user.email, '123456');
expect(context.response.status).toBe(200);
expect(!!context.response.body.id).toBe(true);
@@ -42,20 +48,20 @@ describe('api_sessions', function() {
test('should not login user with wrong password', async function() {
const { user } = await createUserAndSession(1, false);
const context = await koaAppContext({
request: {
method: 'POST',
url: '/api/sessions',
body: {
email: user.email,
password: 'wrong',
},
},
});
{
const context = await postSession(user.email, 'wrong');
expect(context.response.status).toBe(403);
}
await routeHandler(context);
{
const context = await postSession('wrong@wrong.com', '123456');
expect(context.response.status).toBe(403);
}
expect(context.response.status).toBe(403);
{
const context = await postSession('', '');
expect(context.response.status).toBe(403);
}
});
});

View File

@@ -1,23 +1,21 @@
import { SubPath, Route } from '../../utils/routeUtils';
import { ErrorNotFound } from '../../utils/errors';
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { ErrorForbidden } from '../../utils/errors';
import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils';
import { User } from '../../db';
const route: Route = {
const router = new Router();
exec: async function(path: SubPath, ctx: AppContext) {
if (!path.link) {
if (ctx.method === 'POST') {
const user = await bodyFields(ctx.req);
const sessionController = ctx.controllers.apiSession();
const session = await sessionController.authenticate(user.email, user.password);
return { id: session.id };
}
}
router.public = true;
throw new ErrorNotFound(`Invalid link: ${path.link}`);
},
router.post('api/sessions', async (_path: SubPath, ctx: AppContext) => {
const fields: User = await bodyFields(ctx.req);
const user = await ctx.models.user().login(fields.email, fields.password);
if (!user) throw new ErrorForbidden('Invalid username or password');
};
const session = await ctx.models.session().createUserSession(user.id);
return { id: session.id };
});
export default route;
export default router;

View File

@@ -1,5 +1,6 @@
import * as Koa from 'koa';
import { SubPath, Route, Response, ResponseType } from '../utils/routeUtils';
import { SubPath, Response, ResponseType } from '../utils/routeUtils';
import Router from '../utils/Router';
import { ErrorNotFound, ErrorForbidden } from '../utils/errors';
import { dirname, normalize } from 'path';
import { pathExists } from 'fs-extra';
@@ -36,28 +37,25 @@ async function findLocalFile(path: string): Promise<string> {
return localPath;
}
const route: Route = {
const router = new Router();
exec: async function(path: SubPath, ctx: Koa.Context) {
router.public = true;
if (ctx.method === 'GET') {
const localPath = await findLocalFile(path.raw);
// Used to serve static files, so it needs to be public because for example the
// login page, which is public, needs access to the CSS files.
router.get('', async (path: SubPath, ctx: Koa.Context) => {
const localPath = await findLocalFile(path.raw);
let mimeType: string = mime.fromFilename(localPath);
if (!mimeType) mimeType = 'application/octet-stream';
let mimeType: string = mime.fromFilename(localPath);
if (!mimeType) mimeType = 'application/octet-stream';
const fileContent: Buffer = await fs.readFile(localPath);
const fileContent: Buffer = await fs.readFile(localPath);
const koaResponse = ctx.response;
koaResponse.body = fileContent;
koaResponse.set('Content-Type', mimeType);
koaResponse.set('Content-Length', fileContent.length.toString());
return new Response(ResponseType.KoaResponse, koaResponse);
}
const koaResponse = ctx.response;
koaResponse.body = fileContent;
koaResponse.set('Content-Type', mimeType);
koaResponse.set('Content-Length', fileContent.length.toString());
return new Response(ResponseType.KoaResponse, koaResponse);
});
throw new ErrorNotFound();
},
};
export default route;
export default router;

View File

@@ -1,43 +1,125 @@
import { SubPath, Route, respondWithFileContent, redirect } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { SubPath, respondWithFileContent, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext, HttpMethod } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
import { ErrorNotFound } from '../../utils/errors';
import { File } from '../../db';
import { createPaginationLinks, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
import { setQueryParameters } from '../../utils/urlUtils';
import config from '../../config';
import { formatDateTime } from '../../utils/time';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
const route: Route = {
function makeFilePagination(query: any): Pagination {
const limit = Number(query.limit) || pageMaxSize;
const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC);
order.splice(0, 0, { by: 'is_directory', dir: PaginationOrderDir.DESC });
const page: number = 'page' in query ? Number(query.page) : 1;
exec: async function(path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
const output: Pagination = { limit, order, page };
validatePagination(output);
return output;
}
if (ctx.method === 'GET') {
if (!path.link) {
return ctx.controllers.indexFiles().getIndex(sessionId, path.id, ctx.query);
} else if (path.link === 'content') {
const file: File = await ctx.controllers.apiFile().getFileContent(sessionId, path.id);
return respondWithFileContent(ctx.response, file);
}
const router = new Router();
throw new ErrorNotFound();
router.alias(HttpMethod.GET, 'files', 'files/:id');
router.get('files/:id', async (path: SubPath, ctx: AppContext) => {
const dirId = path.id;
const query = ctx.query;
// Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {};
if (query.limit) baseUrlQuery.limit = query.limit;
if (query.order_by) baseUrlQuery.order_by = query.order_by;
if (query.order_dir) baseUrlQuery.order_dir = query.order_dir;
const pagination = makeFilePagination(query);
const owner = ctx.owner;
const fileModel = ctx.models.file({ userId: owner.id });
const root = await fileModel.userRootFile();
const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root;
const parent: File = await fileModel.load(parentTemp.id);
const paginatedFiles = await fileModel.childrens(parent.id, pagination);
const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit);
const parentBaseUrl = await fileModel.fileUrl(parent.id);
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
async function fileToViewItem(file: File, fileFullPaths: Record<string, string>): Promise<any> {
const filePath = fileFullPaths[file.id];
let url = `${config().baseUrl}/files/${filePath}`;
if (!file.is_directory) {
url += '/content';
} else {
url = setQueryParameters(url, baseUrlQuery);
}
if (ctx.method === 'POST') {
const body = await formParse(ctx.req);
const fields = body.fields;
const parentId = fields.parent_id;
const user = await ctx.models.session().sessionUser(sessionId);
return {
name: file.name,
url,
type: file.is_directory ? 'directory' : 'file',
icon: file.is_directory ? 'far fa-folder' : 'far fa-file',
timestamp: formatDateTime(file.updated_time),
mime: !file.is_directory ? (file.mime_type || 'binary') : '',
};
}
if (fields.delete_all_button) {
await ctx.controllers.indexFiles().deleteAll(sessionId, parentId);
} else {
throw new Error('Invalid form button');
}
const files: any[] = [];
return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query));
}
const fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items);
throw new ErrorMethodNotAllowed();
},
if (parent.id !== root.id) {
const p = await fileModel.load(parent.parent_id);
files.push({
...await fileToViewItem(p, await fileModel.itemFullPaths([p])),
icon: 'fas fa-arrow-left',
name: '..',
});
}
};
for (const file of paginatedFiles.items) {
files.push(await fileToViewItem(file, fileFullPaths));
}
export default route;
const view: View = defaultView('files');
view.content.paginatedFiles = { ...paginatedFiles, items: files };
view.content.paginationLinks = paginationLinks;
view.content.postUrl = `${config().baseUrl}/files`;
view.content.parentId = parent.id;
view.cssFiles = ['index/files'];
view.partials.push('pagination');
return view;
});
router.get('files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
let file: File = await fileModel.entityFromItemId(path.id);
file = await fileModel.loadWithContent(file.id);
if (!file) throw new ErrorNotFound();
return respondWithFileContent(ctx.response, file);
});
router.post('files', async (_path: SubPath, ctx: AppContext) => {
const sessionId = contextSessionId(ctx);
const body = await formParse(ctx.req);
const fields = body.fields;
const parentId = fields.parent_id;
const user = await ctx.models.session().sessionUser(sessionId);
if (fields.delete_all_button) {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const parent: File = await fileModel.entityFromItemId(parentId, { returnFullEntity: true });
await fileModel.deleteChildren(parent.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query));
});
export default router;

View File

@@ -0,0 +1,34 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_home', function() {
beforeAll(async () => {
await beforeAllDb('index_home');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should show the home page', async function() {
const { user, session } = await createUserAndSession();
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'GET',
url: '/home',
},
});
await routeHandler(context);
expect(context.response.body.indexOf(user.email) >= 0).toBe(true);
});
});

View File

@@ -1,21 +1,20 @@
import { SubPath, Route } from '../../utils/routeUtils';
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import { contextSessionId } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
import defaultView from '../../utils/defaultView';
const route: Route = {
const router: Router = new Router();
exec: async function(_path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
const homeController = ctx.controllers.indexHome();
router.get('home', async (_path: SubPath, ctx: AppContext) => {
contextSessionId(ctx);
if (ctx.method === 'GET') {
return homeController.getIndex(sessionId);
}
if (ctx.method === 'GET') {
return defaultView('home');
}
throw new ErrorMethodNotAllowed();
},
throw new ErrorMethodNotAllowed();
});
};
export default route;
export default router;

View File

@@ -0,0 +1,74 @@
import { Session } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, parseHtml, createUser } from '../../utils/testing/testUtils';
import { AppContext } from '../../utils/types';
async function doLogin(email: string, password: string): Promise<AppContext> {
const context = await koaAppContext({
request: {
method: 'POST',
url: '/login',
body: {
email: email,
password: password,
},
},
});
await routeHandler(context);
return context;
}
describe('index_login', function() {
beforeAll(async () => {
await beforeAllDb('index_login');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should show the login page', async function() {
const context = await koaAppContext({
request: {
method: 'GET',
url: '/login',
},
});
await routeHandler(context);
const doc = parseHtml(context.response.body);
expect(!!doc.querySelector('input[name=email]')).toBe(true);
expect(!!doc.querySelector('input[name=password]')).toBe(true);
});
test('should login', async function() {
const user = await createUser(1);
const context = await doLogin(user.email, '123456');
const sessionId = context.cookies.get('sessionId');
const session: Session = await models().session().load(sessionId);
expect(session.user_id).toBe(user.id);
});
test('should not login with invalid credentials', async function() {
const user = await createUser(1);
{
const context = await doLogin('bad', '123456');
expect(!context.cookies.get('sessionId')).toBe(true);
}
{
const context = await doLogin(user.email, 'bad');
expect(!context.cookies.get('sessionId')).toBe(true);
}
});
});

View File

@@ -1,33 +1,36 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import { formParse } from '../../utils/requestUtils';
import { baseUrl } from '../../config';
import config from '../../config';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
const route: Route = {
function makeView(error: any = null): View {
const view = defaultView('login');
view.content.error = error;
view.partials = ['errorBanner'];
return view;
}
exec: async function(_path: SubPath, ctx: AppContext) {
const loginController = ctx.controllers.indexLogin();
const router: Router = new Router();
if (ctx.method === 'GET') {
return loginController.getIndex();
}
router.public = true;
if (ctx.method === 'POST') {
try {
const body = await formParse(ctx.req);
const session = await ctx.controllers.apiSession().authenticate(body.fields.email, body.fields.password);
router.get('login', async (_path: SubPath, _ctx: AppContext) => {
return makeView();
});
ctx.cookies.set('sessionId', session.id);
return redirect(ctx, `${baseUrl()}/home`);
} catch (error) {
return loginController.getIndex(error);
}
}
router.post('login', async (_path: SubPath, ctx: AppContext) => {
try {
const body = await formParse(ctx.req);
throw new ErrorMethodNotAllowed();
},
const session = await ctx.models.session().authenticate(body.fields.email, body.fields.password);
ctx.cookies.set('sessionId', session.id);
return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {
return makeView(error);
}
});
};
export default route;
export default router;

View File

@@ -0,0 +1,37 @@
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_logout', function() {
beforeAll(async () => {
await beforeAllDb('index_logout');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should logout', async function() {
const { session } = await createUserAndSession();
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'POST',
url: '/logout',
},
});
expect(context.cookies.get('sessionId')).toBe(session.id);
expect(!!(await models().session().load(session.id))).toBe(true);
await routeHandler(context);
expect(!context.cookies.get('sessionId')).toBe(true);
expect(!!(await models().session().load(session.id))).toBe(false);
});
});

View File

@@ -1,20 +1,16 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import { baseUrl } from '../../config';
import config from '../../config';
import { contextSessionId } from '../../utils/requestUtils';
const route: Route = {
const router = new Router();
exec: async function(_path: SubPath, ctx: AppContext) {
if (ctx.method === 'POST') {
// TODO: also delete the session from the database
ctx.cookies.set('sessionId', '');
return redirect(ctx, `${baseUrl()}/login`);
}
router.post('logout', async (_path: SubPath, ctx: AppContext) => {
const sessionId = contextSessionId(ctx, false);
ctx.cookies.set('sessionId', '');
await ctx.models.session().logout(sessionId);
return redirect(ctx, `${config().baseUrl}/login`);
});
throw new ErrorMethodNotAllowed();
},
};
export default route;
export default router;

View File

@@ -0,0 +1,46 @@
import { NotificationLevel } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_notification', function() {
beforeAll(async () => {
await beforeAllDb('index_notification');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should update notification', async function() {
const { user, session } = await createUserAndSession();
const model = models().notification({ userId: user.id });
await model.add('my_notification', NotificationLevel.Normal, 'testing notification');
const notification = await model.loadByKey('my_notification');
expect(notification.read).toBe(0);
const context = await koaAppContext({
sessionId: session.id,
request: {
method: 'PATCH',
url: `/notifications/${notification.id}`,
body: {
read: 1,
},
},
});
await routeHandler(context);
expect((await model.loadByKey('my_notification')).read).toBe(1);
});
});

View File

@@ -1,20 +1,25 @@
import { SubPath, Route } from '../../utils/routeUtils';
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
import { bodyFields, contextSessionId } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
import { bodyFields } from '../../utils/requestUtils';
import { ErrorNotFound } from '../../utils/errors';
import { Notification } from '../../db';
const route: Route = {
const router = new Router();
exec: async function(path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
router.patch('notifications/:id', async (path: SubPath, ctx: AppContext) => {
const fields: Notification = await bodyFields(ctx.req);
const notificationId = path.id;
const model = ctx.models.notification({ userId: ctx.owner.id });
const existingNotification = await model.load(notificationId);
if (!existingNotification) throw new ErrorNotFound();
if (path.id && ctx.method === 'PATCH') {
return ctx.controllers.indexNotifications().patchOne(sessionId, path.id, await bodyFields(ctx.req));
}
const toSave: Notification = {};
if ('read' in fields) toSave.read = fields.read;
if (!Object.keys(toSave).length) return;
throw new ErrorMethodNotAllowed();
},
toSave.id = notificationId;
await model.save(toSave);
});
};
export default route;
export default router;

View File

@@ -1,47 +0,0 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors';
import { User } from '../../db';
import { baseUrl } from '../../config';
import { hashPassword } from '../../utils/auth';
const route: Route = {
exec: async function(_path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
if (ctx.method === 'GET') {
return ctx.controllers.indexProfile().getIndex(sessionId);
}
if (ctx.method === 'POST') {
let user: User = {};
try {
const body = await formParse(ctx.req);
user = {
id: body.fields.id,
email: body.fields.email,
full_name: body.fields.full_name,
};
if (body.fields.password) {
if (body.fields.password !== body.fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
user.password = hashPassword(body.fields.password);
}
await ctx.controllers.indexProfile().patchIndex(sessionId, user);
return redirect(ctx, `${baseUrl()}/profile`);
} catch (error) {
return ctx.controllers.indexProfile().getIndex(sessionId, user, error);
}
}
throw new ErrorMethodNotAllowed();
},
};
export default route;

View File

@@ -1,59 +0,0 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors';
import { User } from '../../db';
import { baseUrl } from '../../config';
function makeUser(isNew: boolean, fields: any): User {
const user: User = {
email: fields.email,
full_name: fields.full_name,
};
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
user.password = fields.password;
}
if (!isNew) user.id = fields.id;
return user;
}
const route: Route = {
exec: async function(_path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
// if (ctx.method === 'GET') {
// return ctx.controllers.indexUser().getOne(sessionId);
// }
if (ctx.method === 'POST') {
const user: User = {};
try {
const body = await formParse(ctx.req);
const fields = body.fields;
const isNew = !!Number(fields.is_new);
const user = makeUser(isNew, fields);
if (isNew) {
await ctx.controllers.apiUser().postUser(sessionId, user);
} else {
await ctx.controllers.apiUser().patchUser(sessionId, user);
}
return redirect(ctx, `${baseUrl()}/users`);
} catch (error) {
return ctx.controllers.indexProfile().getIndex(sessionId, user, error);
}
}
throw new ErrorMethodNotAllowed();
},
};
export default route;

View File

@@ -0,0 +1,149 @@
import { File, User } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { checkContextError } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml } from '../../utils/testing/testUtils';
export async function postUser(sessionId: string, email: string, password: string): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/users/new',
body: {
email: email,
password: password,
password2: password,
post_button: true,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
export async function patchUser(sessionId: string, user: any): Promise<User> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: '/users',
body: {
...user,
post_button: true,
},
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
export async function getUserHtml(sessionId: string, userId: string): Promise<string> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/users/${userId}`,
},
});
await routeHandler(context);
checkContextError(context);
return context.response.body;
}
describe('index_users', function() {
beforeAll(async () => {
await beforeAllDb('index_users');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a new user along with his root file', async function() {
const { user: admin, session } = await createUserAndSession(1, true);
await postUser(session.id, 'test@example.com', '123456');
const newUser = await models().user({ userId: admin.id }).loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
const userModel = models().user({ userId: newUser.id });
const userFromModel: User = await userModel.load(newUser.id);
expect(!!userFromModel.password).toBe(true);
expect(userFromModel.password === '123456').toBe(false); // Password has been hashed
const fileModel = models().file({ userId: newUser.id });
const rootFile: File = await fileModel.userRootFile();
expect(!!rootFile).toBe(true);
expect(!!rootFile.id).toBe(true);
});
test('should not create anything, neither user, root file nor permissions, if user creation fail', async function() {
const { user, session } = await createUserAndSession(1, true);
const fileModel = models().file({ userId: user.id });
const permissionModel = models().permission();
const userModel = models().user({ userId: user.id });
await postUser(session.id, 'test@example.com', '123456');
const beforeFileCount = (await fileModel.all()).length;
const beforeUserCount = (await userModel.all()).length;
const beforePermissionCount = (await permissionModel.all()).length;
expect(beforeFileCount).toBe(2);
expect(beforeUserCount).toBe(2);
await postUser(session.id, 'test@example.com', '123456');
const afterFileCount = (await fileModel.all()).length;
const afterUserCount = (await userModel.all()).length;
const afterPermissionCount = (await permissionModel.all()).length;
expect(beforeFileCount).toBe(afterFileCount);
expect(beforeUserCount).toBe(afterUserCount);
expect(beforePermissionCount).toBe(afterPermissionCount);
});
test('should change user properties', async function() {
const { user, session } = await createUserAndSession(1, true);
const userModel = models().user({ userId: user.id });
await patchUser(session.id, { id: user.id, email: 'test2@example.com' });
const modUser: User = await userModel.load(user.id);
expect(modUser.email).toBe('test2@example.com');
// const previousPassword = modUser.password;
// await patchUser(session.id, { id: user.id, password: 'abcdefgh', password2: 'abcdefgh' });
// modUser = await userModel.load(user.id);
// expect(!!modUser.password).toBe(true);
// expect(modUser.password === previousPassword).toBe(false);
});
test('should get a user', async function() {
const { user, session } = await createUserAndSession();
const userHtml = await getUserHtml(session.id, user.id);
const doc = parseHtml(userHtml);
// <input class="input" type="email" name="email" value="user1@localhost"/>
expect((doc.querySelector('input[name=email]') as any).value).toBe('user1@localhost');
});
});

View File

@@ -1,15 +1,18 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors';
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext, HttpMethod } from '../../utils/types';
import { formParse } from '../../utils/requestUtils';
import { ErrorUnprocessableEntity } from '../../utils/errors';
import { User } from '../../db';
import { baseUrl } from '../../config';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
function makeUser(isNew: boolean, fields: any): User {
const user: User = {
email: fields.email,
full_name: fields.full_name,
};
const user: User = {};
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
@@ -21,49 +24,87 @@ function makeUser(isNew: boolean, fields: any): User {
return user;
}
const route: Route = {
function userIsNew(path: SubPath): boolean {
return path.id === 'new';
}
exec: async function(path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
const isNew = path.id === 'new';
function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
if (ctx.method === 'GET') {
if (path.id) {
return ctx.controllers.indexUser().getOne(sessionId, isNew, !isNew ? path.id : null);
const router = new Router();
router.get('users', async (_path: SubPath, ctx: AppContext) => {
const userModel = ctx.models.user({ userId: ctx.owner.id });
const users = await userModel.all();
const view: View = defaultView('users');
view.content.users = users;
return view;
});
router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null, error: any = null) => {
const owner = ctx.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const userModel = ctx.models.user({ userId: owner.id });
const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await userModel.load(userId) : null;
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 view: View = defaultView('user');
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.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.partials.push('errorBanner');
return view;
});
router.alias(HttpMethod.POST, 'users/:id', 'users');
router.post('users', async (path: SubPath, ctx: AppContext) => {
let user: User = {};
const userId = userIsMe(path) ? ctx.owner.id : path.id;
try {
const body = await formParse(ctx.req);
const fields = body.fields;
if (userIsMe(path)) fields.id = userId;
user = makeUser(userIsNew(path), fields);
const userModel = ctx.models.user({ userId: ctx.owner.id });
if (fields.post_button) {
if (userIsNew(path)) {
await userModel.save(userModel.fromApiInput(user));
} else {
return ctx.controllers.indexUser().getIndex(sessionId);
await userModel.save(userModel.fromApiInput(user), { isNew: false });
}
} else if (fields.delete_button) {
await userModel.delete(path.id);
} else {
throw new Error('Invalid form button');
}
if (ctx.method === 'POST') {
let user: User = {};
return redirect(ctx, `${config().baseUrl}/users${userIsMe(path) ? '/me' : ''}`);
} catch (error) {
const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id');
return endPoint(path, ctx, user, error);
}
});
try {
const body = await formParse(ctx.req);
const fields = body.fields;
user = makeUser(isNew, fields);
if (fields.post_button) {
if (isNew) {
await ctx.controllers.apiUser().postUser(sessionId, user);
} else {
await ctx.controllers.apiUser().patchUser(sessionId, user);
}
} else if (fields.delete_button) {
await ctx.controllers.apiUser().deleteUser(sessionId, path.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, `${baseUrl()}/users`);
} catch (error) {
return ctx.controllers.indexUser().getOne(sessionId, isNew, user, error);
}
}
throw new ErrorMethodNotAllowed();
},
};
export default route;
export default router;

View File

@@ -25,3 +25,65 @@
// };
// export default route;
// ORIGINAL CONTROLLER:
// import BaseController from '../BaseController';
// import mustacheService from '../../services/MustacheService';
// import { ErrorNotFound } from '../../utils/errors';
// import uuidgen from '../../utils/uuidgen';
// import controllers from '../factory';
// export default class OAuthController extends BaseController {
// async getAuthorize(query: any): Promise<string> {
// const clientModel = this.models.apiClient();
// const client = await clientModel.load(query.client_id);
// if (!client) throw new ErrorNotFound(`client_id missing or invalid client ID: ${query.client_id}`);
// return mustacheService.render('oauth2/authorize', {
// response_type: query.response_type,
// client: client,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// }
// async postAuthorize(query: any): Promise<string> {
// const clientModel = this.models.apiClient();
// const sessionModel = this.models.session();
// const sessionController = controllers(this.models).session();
// let client = null;
// try {
// client = await clientModel.load(query.client_id);
// if (!client) throw new ErrorNotFound(`client_id missing or invalid client ID: ${query.client_id}`);
// const session = await sessionController.authenticate(query.email, query.password);
// const authCode = uuidgen(32);
// await sessionModel.save({ id: session.id, auth_code: authCode });
// return mustacheService.render('oauth2/authcode', {
// client: client,
// authCode: authCode,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// } catch (error) {
// return mustacheService.render('oauth2/authorize', {
// response_type: query.response_type,
// client: client,
// error: error,
// }, {
// cssFiles: ['oauth2/authorize'],
// });
// }
// }
// }

View File

@@ -1,4 +1,4 @@
import { Routes } from '../utils/routeUtils';
import { Routers } from '../utils/routeUtils';
import apiSessions from './api/sessions';
import apiPing from './api/ping';
@@ -6,14 +6,12 @@ import apiFiles from './api/files';
import indexLoginRoute from './index/login';
import indexLogoutRoute from './index/logout';
import indexHomeRoute from './index/home';
import indexProfileRoute from './index/profile';
import indexUsersRoute from './index/users';
import indexUserRoute from './index/user';
import indexFilesRoute from './index/files';
import indexNotificationsRoute from './index/notifications';
import defaultRoute from './default';
const routes: Routes = {
const routes: Routers = {
'api/ping': apiPing,
'api/sessions': apiSessions,
'api/files': apiFiles,
@@ -21,9 +19,7 @@ const routes: Routes = {
'login': indexLoginRoute,
'logout': indexLogoutRoute,
'home': indexHomeRoute,
'profile': indexProfileRoute,
'users': indexUsersRoute,
'user': indexUserRoute,
'files': indexFilesRoute,
'notifications': indexNotificationsRoute,

View File

@@ -1,6 +1,6 @@
import * as Mustache from 'mustache';
import * as fs from 'fs-extra';
import config, { baseUrl } from '../config';
import config from '../config';
export interface RenderOptions {
partials?: any;
@@ -30,7 +30,7 @@ class MustacheService {
private get defaultLayoutOptions(): any {
return {
baseUrl: baseUrl(),
baseUrl: config().baseUrl,
};
}
@@ -41,7 +41,7 @@ class MustacheService {
private resolvesFilePaths(type: string, paths: string[]): string[] {
const output: string[] = [];
for (const path of paths) {
output.push(`${baseUrl()}/${type}/${path}.${type}`);
output.push(`${config().baseUrl}/${type}/${path}.${type}`);
}
return output;
}

View File

@@ -33,7 +33,7 @@ export async function createDb(config: DatabaseConfig, options: CreateDbOptions
await execCommand(cmd.join(' '));
} else if (config.client === 'sqlite3') {
const filePath = sqliteFilePath(config);
const filePath = sqliteFilePath(config.name);
if (await fs.pathExists(filePath)) {
if (options.dropIfExists) {
@@ -71,6 +71,6 @@ export async function dropDb(config: DatabaseConfig, options: DropDbOptions = nu
throw error;
}
} else if (config.client === 'sqlite3') {
await fs.remove(sqliteFilePath(config));
await fs.remove(sqliteFilePath(config.name));
}
}

View File

@@ -0,0 +1,62 @@
import { ErrorMethodNotAllowed, ErrorNotFound } from './errors';
import { HttpMethod } from './types';
import { RouteResponseFormat, RouteHandler } from './routeUtils';
export default class Router {
public public: boolean = false;
public responseFormat: RouteResponseFormat = null;
private routes_: Record<string, Record<string, RouteHandler>> = {};
private aliases_: Record<string, Record<string, string>> = {};
public findEndPoint(method: HttpMethod, schema: string): RouteHandler {
if (this.aliases_[method]?.[schema]) { return this.findEndPoint(method, this.aliases_[method]?.[schema]); }
if (!this.routes_[method]) { throw new ErrorMethodNotAllowed(`Not allowed: ${method} ${schema}`); }
const endPoint = this.routes_[method][schema];
if (!endPoint) { throw new ErrorNotFound(`Not found: ${method} ${schema}`); }
let endPointFn = endPoint;
for (let i = 0; i < 1000; i++) {
if (typeof endPointFn === 'string') {
endPointFn = this.routes_[method]?.[endPointFn];
} else {
return endPointFn;
}
}
throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`);
}
public alias(method: HttpMethod, path: string, target: string) {
if (!this.aliases_[method]) { this.aliases_[method] = {}; }
this.aliases_[method][path] = target;
}
public get(path: string, handler: RouteHandler) {
if (!this.routes_.GET) { this.routes_.GET = {}; }
this.routes_.GET[path] = handler;
}
public post(path: string, handler: RouteHandler) {
if (!this.routes_.POST) { this.routes_.POST = {}; }
this.routes_.POST[path] = handler;
}
public patch(path: string, handler: RouteHandler) {
if (!this.routes_.PATCH) { this.routes_.PATCH = {}; }
this.routes_.PATCH[path] = handler;
}
public del(path: string, handler: RouteHandler) {
if (!this.routes_.DELETE) { this.routes_.DELETE = {}; }
this.routes_.DELETE[path] = handler;
}
public put(path: string, handler: RouteHandler) {
if (!this.routes_.PUT) { this.routes_.PUT = {}; }
this.routes_.PUT[path] = handler;
}
}

View File

@@ -2,6 +2,8 @@
// https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
class ApiError extends Error {
public static httpCode: number = 400;
public httpCode: number;
public code: string;
public constructor(message: string, httpCode: number = 400, code: string = undefined) {
@@ -13,51 +15,65 @@ class ApiError extends Error {
}
export class ErrorMethodNotAllowed extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Method Not Allowed') {
super(message, 405);
super(message, ErrorMethodNotAllowed.httpCode);
Object.setPrototypeOf(this, ErrorMethodNotAllowed.prototype);
}
}
export class ErrorNotFound extends ApiError {
public static httpCode: number = 404;
public constructor(message: string = 'Not Found') {
super(message, 404);
super(message, ErrorNotFound.httpCode);
Object.setPrototypeOf(this, ErrorNotFound.prototype);
}
}
export class ErrorForbidden extends ApiError {
public static httpCode: number = 403;
public constructor(message: string = 'Forbidden') {
super(message, 403);
super(message, ErrorForbidden.httpCode);
Object.setPrototypeOf(this, ErrorForbidden.prototype);
}
}
export class ErrorBadRequest extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Bad Request') {
super(message, 400);
super(message, ErrorBadRequest.httpCode);
Object.setPrototypeOf(this, ErrorBadRequest.prototype);
}
}
export class ErrorUnprocessableEntity extends ApiError {
public static httpCode: number = 422;
public constructor(message: string = 'Unprocessable Entity') {
super(message, 422);
super(message, ErrorUnprocessableEntity.httpCode);
Object.setPrototypeOf(this, ErrorUnprocessableEntity.prototype);
}
}
export class ErrorConflict extends ApiError {
public static httpCode: number = 409;
public constructor(message: string = 'Conflict') {
super(message, 409);
super(message, ErrorConflict.httpCode);
Object.setPrototypeOf(this, ErrorConflict.prototype);
}
}
export class ErrorResyncRequired extends ApiError {
public static httpCode: number = 400;
public constructor(message: string = 'Delta cursor is invalid and the complete data should be resynced') {
super(message, 400, 'resyncRequired');
super(message, ErrorResyncRequired.httpCode, 'resyncRequired');
Object.setPrototypeOf(this, ErrorResyncRequired.prototype);
}
}

View File

@@ -48,9 +48,11 @@ export function headerSessionId(headers: any): string {
return headers['x-api-auth'] ? headers['x-api-auth'] : '';
}
export function contextSessionId(ctx: AppContext): string {
export function contextSessionId(ctx: AppContext, throwIfNotFound = true): string {
if (ctx.headers['x-api-auth']) return ctx.headers['x-api-auth'];
const id = ctx.cookies.get('sessionId');
if (!id) throw new ErrorForbidden('Invalid or missing session');
if (!id && throwIfNotFound) throw new ErrorForbidden('Invalid or missing session');
return id;
}

View File

@@ -18,7 +18,7 @@ describe('routeUtils', function() {
const link = t[2];
const addressingType = t[3];
const parsed = parseSubPath(path);
const parsed = parseSubPath('', path);
expect(parsed.id).toBe(id);
expect(parsed.link).toBe(link);
expect(parsed.addressingType).toBe(addressingType);

View File

@@ -1,5 +1,6 @@
import { File, ItemAddressingType } from '../db';
import { ErrorBadRequest } from './errors';
import Router from './Router';
import { AppContext } from './types';
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
@@ -22,13 +23,10 @@ export enum RouteResponseFormat {
Json = 'json',
}
export interface Route {
exec: Function;
responseFormat?: RouteResponseFormat;
}
export type RouteHandler = (path: SubPath, ctx: AppContext, ...args: any[])=> Promise<any>;
export interface Routes {
[key: string]: Route;
export interface Routers {
[key: string]: Router;
}
export interface SubPath {
@@ -36,10 +34,11 @@ export interface SubPath {
link: string;
addressingType: ItemAddressingType;
raw: string;
schema: string;
}
export interface MatchedRoute {
route: Route;
route: Router;
basePath: string;
subPath: SubPath;
}
@@ -113,7 +112,7 @@ export function isPathBasedAddressing(fileId: string): boolean {
//
// root:/Documents/MyFile.md:/content
// ABCDEFG/content
export function parseSubPath(p: string): SubPath {
export function parseSubPath(basePath: string, p: string): SubPath {
p = rtrimSlashes(ltrimSlashes(p));
const output: SubPath = {
@@ -121,6 +120,7 @@ export function parseSubPath(p: string): SubPath {
link: '',
addressingType: ItemAddressingType.Id,
raw: p,
schema: '',
};
const colonIndex1 = p.indexOf(':');
@@ -141,10 +141,18 @@ export function parseSubPath(p: string): SubPath {
if (s.length >= 2) output.link = s[1];
}
if (basePath) {
const schema = [basePath];
if (output.id) schema.push(':id');
if (output.link) schema.push(output.link);
output.schema = schema.join('/');
}
return output;
}
export function routeResponseFormat(match: MatchedRoute, rawPath: string): RouteResponseFormat {
export function routeResponseFormat(match: MatchedRoute, context: AppContext): RouteResponseFormat {
const rawPath = context.path;
if (match && match.route.responseFormat) return match.route.responseFormat;
let path = rawPath;
@@ -153,18 +161,30 @@ export function routeResponseFormat(match: MatchedRoute, rawPath: string): Route
return path.indexOf('api') === 0 || path.indexOf('/api') === 0 ? RouteResponseFormat.Json : RouteResponseFormat.Html;
}
export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
// In a path such as "/api/files/SOME_ID/content" we want to find:
// - The base path: "api/files"
// - The ID: "SOME_ID"
// - The link: "content"
export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
const splittedPath = path.split('/');
// Because the path starts with "/", we remove the first element, which is
// an empty string. So for example we now have ['api', 'files', 'SOME_ID', 'content'].
splittedPath.splice(0, 1);
if (splittedPath.length >= 2) {
// Create the base path, eg. "api/files", to match it to one of the
// routes.s
const basePath = `${splittedPath[0]}/${splittedPath[1]}`;
if (routes[basePath]) {
// Remove the base path from the array so that parseSubPath() can
// extract the ID and link from the URL. So the array will contain
// at this point: ['SOME_ID', 'content'].
splittedPath.splice(0, 2);
return {
route: routes[basePath],
basePath: basePath,
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
};
}
}
@@ -175,7 +195,7 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
return {
route: routes[basePath],
basePath: basePath,
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
};
}
@@ -183,7 +203,7 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
return {
route: routes[''],
basePath: '',
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath('', `/${splittedPath.join('/')}`),
};
}

View File

@@ -0,0 +1,194 @@
// These utility functions allow making API calls easily from test units.
// There's two versions of each function:
//
// - A regular one, eg. "postDirectory", which returns whatever would have
// normally return the API call. It also checks for error.
//
// - The other function is suffixed with "Context", eg "postDirectoryContext".
// In that case, it returns the complete Koa context, which can be used in
// particular to access the response object and test for errors.
import { File } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { PaginatedResults, Pagination, paginationToQueryParams } from '../../models/utils/pagination';
import { AppContext } from '../types';
import { koaAppContext } from './testUtils';
export function checkContextError(context: AppContext) {
if (context.response.status >= 400) throw new Error(`Cannot create directory: ${JSON.stringify(context.response)}`);
}
export async function getFileMetadataContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}`,
},
});
await routeHandler(context);
return context;
}
export async function getFileMetadata(sessionId: string, path: string): Promise<File> {
const context = await getFileMetadataContext(sessionId, path);
checkContextError(context);
return context.response.body;
}
export async function deleteFileContentContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'DELETE',
url: `/api/files/${path}/content`,
},
});
await routeHandler(context);
return context;
}
export async function deleteFileContent(sessionId: string, path: string): Promise<void> {
const context = await deleteFileContentContext(sessionId, path);
checkContextError(context);
}
export async function deleteFileContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'DELETE',
url: `/api/files/${path}`,
},
});
await routeHandler(context);
return context;
}
export async function deleteFile(sessionId: string, path: string): Promise<void> {
const context = await deleteFileContext(sessionId, path);
checkContextError(context);
}
export async function postDirectoryContext(sessionId: string, parentPath: string, name: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: `/api/files/${parentPath}/children`,
body: {
is_directory: 1,
name: name,
},
},
});
await routeHandler(context);
return context;
}
export async function postDirectory(sessionId: string, parentPath: string, name: string): Promise<File> {
const context = await postDirectoryContext(sessionId, parentPath, name);
checkContextError(context);
return context.response.body;
}
export async function getDirectoryChildrenContext(sessionId: string, path: string, pagination: Pagination = null): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/children`,
query: paginationToQueryParams(pagination),
},
});
await routeHandler(context);
return context;
}
export async function getDirectoryChildren(sessionId: string, path: string, pagination: Pagination = null): Promise<PaginatedResults> {
const context = await getDirectoryChildrenContext(sessionId, path, pagination);
checkContextError(context);
return context.response.body;
}
export async function putFileContentContext(sessionId: string, path: string, filePath: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'PUT',
url: `/api/files/${path}/content`,
files: { file: { path: filePath } },
},
});
await routeHandler(context);
return context;
}
export async function putFileContent(sessionId: string, path: string, filePath: string): Promise<File> {
const context = await putFileContentContext(sessionId, path, filePath);
checkContextError(context);
return context.response.body;
}
export async function getFileContentContext(sessionId: string, path: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/content`,
},
});
await routeHandler(context);
return context;
}
export async function getFileContent(sessionId: string, path: string): Promise<Buffer> {
const context = await getFileContentContext(sessionId, path);
checkContextError(context);
return context.response.body;
}
export async function patchFileContext(sessionId: string, path: string, file: File): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'PATCH',
url: `/api/files/${path}`,
body: file,
},
});
await routeHandler(context);
return context;
}
export async function patchFile(sessionId: string, path: string, file: File): Promise<File> {
const context = await patchFileContext(sessionId, path, file);
checkContextError(context);
return context.response.body;
}
export async function getDeltaContext(sessionId: string, path: string, pagination: Pagination): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'GET',
url: `/api/files/${path}/delta`,
query: paginationToQueryParams(pagination),
},
});
await routeHandler(context);
return context;
}
export async function getDelta(sessionId: string, path: string, pagination: Pagination): Promise<PaginatedResults> {
const context = await getDeltaContext(sessionId, path, pagination);
checkContextError(context);
return context.response.body;
}

View File

@@ -1,16 +1,17 @@
import { User, Session, DbConnection, connectDb, disconnectDb, File, truncateTables } from '../../db';
import { User, Session, DbConnection, connectDb, disconnectDb, File, truncateTables, sqliteFilePath } from '../../db';
import { createDb } from '../../tools/dbTools';
import modelFactory from '../../models/factory';
import controllerFactory from '../../controllers/factory';
import baseConfig from '../../config-tests';
import { AppContext, Config, Env } from '../types';
import { initConfig } from '../../config';
import { AppContext, Env } from '../types';
import config, { initConfig } from '../../config';
import FileModel from '../../models/FileModel';
import Logger from '@joplin/lib/Logger';
import FakeCookies from './koa/FakeCookies';
import FakeRequest from './koa/FakeRequest';
import FakeResponse from './koa/FakeResponse';
import * as httpMocks from 'node-mocks-http';
import * as crypto from 'crypto';
import * as fs from 'fs-extra';
import * as jsdom from 'jsdom';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@@ -20,23 +21,46 @@ let db_: DbConnection = null;
// require('source-map-support').install();
export async function beforeAllDb(unitName: string) {
const config: Config = {
...baseConfig,
database: {
...baseConfig.database,
name: unitName,
},
};
initConfig(config);
await createDb(config.database, { dropIfExists: true });
db_ = await connectDb(config.database);
export function randomHash(): string {
return crypto.createHash('md5').update(`${Date.now()}-${Math.random()}`).digest('hex');
}
export async function afterAllDb() {
await disconnectDb(db_);
db_ = null;
let tempDir_: string = null;
export async function tempDir(): Promise<string> {
if (tempDir_) return tempDir_;
tempDir_ = `${packageRootDir}/temp/${randomHash()}`;
await fs.mkdirp(tempDir_);
return tempDir_;
}
let createdDbName_: string = null;
export async function beforeAllDb(unitName: string) {
createdDbName_ = unitName;
initConfig({
SQLITE_DATABASE: createdDbName_,
});
await createDb(config().database, { dropIfExists: true });
db_ = await connectDb(config().database);
}
export async function afterAllTests() {
if (db_) {
await disconnectDb(db_);
db_ = null;
}
if (tempDir_) {
await fs.remove(tempDir_);
tempDir_ = null;
}
if (createdDbName_) {
const filePath = sqliteFilePath(createdDbName_);
await fs.remove(filePath);
createdDbName_ = null;
}
}
export async function beforeEachDb() {
@@ -44,7 +68,7 @@ export async function beforeEachDb() {
}
interface AppContextTestOptions {
owner?: User;
// owner?: User;
sessionId?: string;
request?: any;
}
@@ -79,6 +103,8 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
const req = httpMocks.createRequest(reqOptions);
req.__isMocked = true;
const owner = options.sessionId ? await models().session().sessionUser(options.sessionId) : null;
const appLogger = Logger.create('AppTest');
// Set type to "any" because the Koa context has many properties and we
@@ -88,16 +114,15 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
appContext.env = Env.Dev;
appContext.db = db_;
appContext.models = models();
appContext.controllers = controllers();
appContext.appLogger = () => appLogger;
appContext.path = req.url;
appContext.owner = options.owner;
appContext.owner = owner;
appContext.cookies = new FakeCookies();
appContext.request = new FakeRequest(req);
appContext.response = new FakeResponse();
appContext.headers = { ...reqOptions.headers };
appContext.req = req;
appContext.query = req.query;
appContext.method = req.method;
if (options.sessionId) {
@@ -130,8 +155,9 @@ export function models() {
return modelFactory(db(), baseUrl());
}
export function controllers() {
return controllerFactory(models());
export function parseHtml(html: string): Document {
const dom = new jsdom.JSDOM(html);
return dom.window.document;
}
interface CreateUserAndSessionOptions {
@@ -140,8 +166,6 @@ interface CreateUserAndSessionOptions {
}
export const createUserAndSession = async function(index: number = 1, isAdmin: boolean = false, options: CreateUserAndSessionOptions = null): Promise<UserAndSession> {
const sessionController = controllers().apiSession();
options = {
email: `user${index}@localhost`,
password: '123456',
@@ -149,7 +173,7 @@ export const createUserAndSession = async function(index: number = 1, isAdmin: b
};
const user = await models().user().save({ email: options.email, password: options.password, is_admin: isAdmin ? 1 : 0 }, { skipValidation: true });
const session = await sessionController.authenticate(options.email, options.password);
const session = await models().session().authenticate(options.email, options.password);
return {
user: user,

View File

@@ -1,6 +1,5 @@
import { LoggerWrapper } from '@joplin/lib/Logger';
import * as Koa from 'koa';
import { Controllers } from '../controllers/factory';
import { DbConnection, User, Uuid } from '../db';
import { Models } from '../models/factory';
@@ -21,14 +20,18 @@ export interface AppContext extends Koa.Context {
env: Env;
db: DbConnection;
models: Models;
controllers: Controllers;
appLogger(): LoggerWrapper;
notifications: NotificationView[];
owner: User;
}
export enum DatabaseConfigClient {
PostgreSQL = 'pg',
SQLite = 'sqlite3',
}
export interface DatabaseConfig {
client: string;
client: DatabaseConfigClient;
name: string;
host?: string;
port?: number;
@@ -42,8 +45,19 @@ export interface Config {
rootDir: string;
viewDir: string;
layoutDir: string;
// Not that, for now, nothing is being logged to file. Log is just printed
// to stdout, which is then handled by Docker own log mechanism
logDir: string;
database: DatabaseConfig;
baseUrl: string;
}
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
DELETE = 'DELETE',
PATCH = 'PATCH',
HEAD = 'HEAD',
}
export type KoaNext = ()=> Promise<void>;

View File

@@ -1,31 +0,0 @@
{{> errorBanner}}
<form action="{{{global.baseUrl}}}/profile" method="POST">
<input type="hidden" name="id" value="{{user.id}}"/>
<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>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password" autocomplete="new-password"/>
</div>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
</div>
<div class="control">
<button class="button is-primary">Update profile</button>
</div>
</form>

View File

@@ -28,11 +28,9 @@
</div>
<div class="control">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{^isNew}}
{{^user.is_admin}}
<input type="submit" name="delete_button" class="button is-danger" value="Delete" />
{{/user.is_admin}}
{{/isNew}}
{{#showDeleteButton}}
<input type="submit" name="delete_button" class="button is-danger" value="Delete" />
{{/showDeleteButton}}
</div>
</form>

View File

@@ -13,8 +13,8 @@
</head>
<body class="page-{{{pageName}}}">
{{> navbar}}
{{> notifications}}
<main class="main">
{{> notifications}}
{{{contentHtml}}}
</main>
</body>

View File

@@ -14,7 +14,7 @@
</div>
<div class="navbar-end">
<div class="navbar-item">{{global.owner.email}}</div>
<a class="navbar-item" href="{{{global.baseUrl}}}/profile">Profile</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>

View File

@@ -1,9 +1,11 @@
{{#global.notifications}}
<div class="notification is-{{level}}" id="notification-{{id}}">
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
{{{messageHtml}}}
</div>
{{/global.notifications}}
{{#global.hasNotifications}}
{{#global.notifications}}
<div class="notification is-{{level}}" id="notification-{{id}}">
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
{{{messageHtml}}}
</div>
{{/global.notifications}}
{{/global.hasNotifications}}
<script>
onDocumentReady(function() {

View File

@@ -194,8 +194,6 @@ async function main() {
let manifests: any = {};
// TODO: validate plugin ID when publishing
for (const npmPackage of npmPackages) {
try {
const packageName = npmPackage.name;

Binary file not shown.

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-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
#: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134
@@ -61,7 +63,7 @@ msgstr "Annuler"
msgid ""
"The app is now going to close. Please relaunch it to complete the process."
msgstr ""
"L'application va maintenance fermer. Veuillez la relancer pour terminer "
"L'application va maintenant se fermer. Veuillez la relancer pour terminer "
"l'opération."
#: packages/app-desktop/plugins/GotoAnything.js:431
@@ -2059,6 +2061,8 @@ msgid ""
"The default admin password is insecure and has not been changed! [Change it "
"now](%s)"
msgstr ""
"Le mot de passe admin par défaut n'est pas sécurisé et n'a pas été modifié ! "
"[Changez-le maintenant](%s)"
#: packages/lib/onedrive-api-node-utils.js:46
#, javascript-format

View File

@@ -1,35 +1,28 @@
import * as fs from 'fs-extra';
const { execCommand, execCommandVerbose, rootDir, gitPullTry } = require('./tool-utils.js');
const { execCommand2, rootDir, gitPullTry } = require('./tool-utils.js');
const serverDir = `${rootDir}/packages/server`;
const readmePath = `${serverDir}/README.md`;
async function updateReadmeLinkVersion(version: string) {
const content = await fs.readFile(readmePath, 'utf8');
const newContent = content.replace(/server-v(.*?).tar.gz/g, `server-${version}.tar.gz`);
if (content === newContent) throw new Error(`Could not change version number in ${readmePath}`);
await fs.writeFile(readmePath, newContent, 'utf8');
}
async function main() {
process.chdir(serverDir);
console.info(`Running from: ${process.cwd()}`);
await gitPullTry();
const version = (await execCommand('npm version patch')).trim();
process.chdir(serverDir);
const version = (await execCommand2('npm version patch')).trim();
const versionShort = version.substr(1);
const tagName = `server-${version}`;
console.info(`New version number: ${version}`);
process.chdir(rootDir);
console.info(`Running from: ${process.cwd()}`);
await updateReadmeLinkVersion(version);
await execCommand2(`docker build -t "joplin/server:${versionShort}" -f Dockerfile.server .`);
await execCommand2(`docker tag "joplin/server:${versionShort}" "joplin/server:latest"`);
await execCommand2(`docker push joplin/server:${versionShort}`);
await execCommand2('docker push joplin/server:latest');
await execCommandVerbose('git', ['add', '-A']);
await execCommandVerbose('git', ['commit', '-m', `Server release ${version}`]);
await execCommandVerbose('git', ['tag', tagName]);
await execCommandVerbose('git', ['push']);
await execCommandVerbose('git', ['push', '--tags']);
await execCommand2('git add -A');
await execCommand2(`git commit -m 'Server release ${version}'`);
await execCommand2(`git tag ${tagName}`);
await execCommand2('git push');
await execCommand2('git push --tags');
}
main().catch((error) => {

View File

@@ -2,6 +2,7 @@ const fetch = require('node-fetch');
const fs = require('fs-extra');
const execa = require('execa');
const { execSync } = require('child_process');
const { splitCommandString } = require('@joplin/lib/string-utils');
const toolUtils = {};
@@ -55,6 +56,29 @@ toolUtils.execCommandVerbose = function(commandName, args = []) {
return promise;
};
// There's lot of execCommandXXX functions, but eventually all scripts should
// use the one below, which supports:
//
// - Printing the command being executed
// - Printing the output in real time (piping to stdout)
// - Returning the command result as string
toolUtils.execCommand2 = async function(command, options = null) {
options = {
showInput: true,
showOutput: true,
...options,
};
if (options.showInput) console.info(`> ${command}`);
const args = splitCommandString(command);
const executableName = args[0];
args.splice(0, 1);
const promise = execa(executableName, args);
if (options.showOutput) promise.stdout.pipe(process.stdout);
const result = await promise;
return result.stdout;
};
toolUtils.execCommandWithPipes = function(executable, args) {
const spawn = require('child_process').spawn;