You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-02-01 07:49:31 +02:00
Compare commits
13 Commits
plugin-gen
...
server_fil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8801e82cab | ||
|
|
c4757d6c60 | ||
|
|
af6c79b844 | ||
|
|
0e0de1207f | ||
|
|
2fda067034 | ||
|
|
29177330b0 | ||
|
|
66a5490b54 | ||
|
|
469cd19ec1 | ||
|
|
41684a64ef | ||
|
|
2cd7839552 | ||
|
|
c3d4617612 | ||
|
|
158fafc4a0 | ||
|
|
a97f25fd61 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
**/node_modules
|
||||
Assets/
|
||||
.git/
|
||||
_releases/
|
||||
packages/app-desktop
|
||||
packages/app-cli
|
||||
packages/app-mobile
|
||||
packages/app-clipper
|
||||
packages/generator-joplin
|
||||
9
.env-sample
Normal file
9
.env-sample
Normal file
@@ -0,0 +1,9 @@
|
||||
# Example of local config, for development:
|
||||
#
|
||||
# JOPLIN_BASE_URL=http://localhost:22300
|
||||
# JOPLIN_PORT=22300
|
||||
|
||||
# Example of config for production:
|
||||
#
|
||||
# JOPLIN_BASE_URL=https://example.com/joplin
|
||||
# JOPLIN_PORT=22300
|
||||
217
.eslintignore
217
.eslintignore
@@ -6,6 +6,7 @@ _releases/
|
||||
**/node_modules/
|
||||
Assets/
|
||||
docs/
|
||||
packages/server/dist/
|
||||
highlight.pack.js
|
||||
Modules/TinyMCE/IconPack/postinstall.js
|
||||
Modules/TinyMCE/JoplinLists/
|
||||
@@ -889,12 +890,18 @@ packages/lib/InMemoryCache.js.map
|
||||
packages/lib/JoplinServerApi.d.ts
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/JoplinServerApi.js.map
|
||||
packages/lib/JoplinServerApi2.d.ts
|
||||
packages/lib/JoplinServerApi2.js
|
||||
packages/lib/JoplinServerApi2.js.map
|
||||
packages/lib/Logger.d.ts
|
||||
packages/lib/Logger.js
|
||||
packages/lib/Logger.js.map
|
||||
packages/lib/PoorManIntervals.d.ts
|
||||
packages/lib/PoorManIntervals.js
|
||||
packages/lib/PoorManIntervals.js.map
|
||||
packages/lib/SyncTargetJoplinServer.d.ts
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetJoplinServer.js.map
|
||||
packages/lib/Synchronizer.d.ts
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/Synchronizer.js.map
|
||||
@@ -916,6 +923,9 @@ packages/lib/errorUtils.js.map
|
||||
packages/lib/eventManager.d.ts
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/eventManager.js.map
|
||||
packages/lib/file-api-driver-joplinServer.d.ts
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-joplinServer.js.map
|
||||
packages/lib/fs-driver-base.d.ts
|
||||
packages/lib/fs-driver-base.js
|
||||
packages/lib/fs-driver-base.js.map
|
||||
@@ -1387,4 +1397,211 @@ packages/renderer/pathUtils.js.map
|
||||
packages/renderer/utils.d.ts
|
||||
packages/renderer/utils.js
|
||||
packages/renderer/utils.js.map
|
||||
packages/server/src/app.d.ts
|
||||
packages/server/src/app.js
|
||||
packages/server/src/app.js.map
|
||||
packages/server/src/config-base.d.ts
|
||||
packages/server/src/config-base.js
|
||||
packages/server/src/config-base.js.map
|
||||
packages/server/src/config-buildTypes.d.ts
|
||||
packages/server/src/config-buildTypes.js
|
||||
packages/server/src/config-buildTypes.js.map
|
||||
packages/server/src/config-dev.d.ts
|
||||
packages/server/src/config-dev.js
|
||||
packages/server/src/config-dev.js.map
|
||||
packages/server/src/config-prod.d.ts
|
||||
packages/server/src/config-prod.js
|
||||
packages/server/src/config-prod.js.map
|
||||
packages/server/src/config-tests.d.ts
|
||||
packages/server/src/config-tests.js
|
||||
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/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
|
||||
packages/server/src/migrations/20190913171451_create.d.ts
|
||||
packages/server/src/migrations/20190913171451_create.js
|
||||
packages/server/src/migrations/20190913171451_create.js.map
|
||||
packages/server/src/models/ApiClientModel.d.ts
|
||||
packages/server/src/models/ApiClientModel.js
|
||||
packages/server/src/models/ApiClientModel.js.map
|
||||
packages/server/src/models/BaseModel.d.ts
|
||||
packages/server/src/models/BaseModel.js
|
||||
packages/server/src/models/BaseModel.js.map
|
||||
packages/server/src/models/ChangeModel.d.ts
|
||||
packages/server/src/models/ChangeModel.js
|
||||
packages/server/src/models/ChangeModel.js.map
|
||||
packages/server/src/models/ChangeModel.test.d.ts
|
||||
packages/server/src/models/ChangeModel.test.js
|
||||
packages/server/src/models/ChangeModel.test.js.map
|
||||
packages/server/src/models/FileModel.d.ts
|
||||
packages/server/src/models/FileModel.js
|
||||
packages/server/src/models/FileModel.js.map
|
||||
packages/server/src/models/FileModel.test.d.ts
|
||||
packages/server/src/models/FileModel.test.js
|
||||
packages/server/src/models/FileModel.test.js.map
|
||||
packages/server/src/models/PermissionModel.d.ts
|
||||
packages/server/src/models/PermissionModel.js
|
||||
packages/server/src/models/PermissionModel.js.map
|
||||
packages/server/src/models/SessionModel.d.ts
|
||||
packages/server/src/models/SessionModel.js
|
||||
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/factory.d.ts
|
||||
packages/server/src/models/factory.js
|
||||
packages/server/src/models/factory.js.map
|
||||
packages/server/src/models/utils/pagination.d.ts
|
||||
packages/server/src/models/utils/pagination.js
|
||||
packages/server/src/models/utils/pagination.js.map
|
||||
packages/server/src/models/utils/pagination.test.d.ts
|
||||
packages/server/src/models/utils/pagination.test.js
|
||||
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/ping.d.ts
|
||||
packages/server/src/routes/api/ping.js
|
||||
packages/server/src/routes/api/ping.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/default.d.ts
|
||||
packages/server/src/routes/default.js
|
||||
packages/server/src/routes/default.js.map
|
||||
packages/server/src/routes/index/files.d.ts
|
||||
packages/server/src/routes/index/files.js
|
||||
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/login.d.ts
|
||||
packages/server/src/routes/index/login.js
|
||||
packages/server/src/routes/index/login.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/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/users.d.ts
|
||||
packages/server/src/routes/index/users.js
|
||||
packages/server/src/routes/index/users.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
|
||||
packages/server/src/routes/routes.d.ts
|
||||
packages/server/src/routes/routes.js
|
||||
packages/server/src/routes/routes.js.map
|
||||
packages/server/src/services/MustacheService.d.ts
|
||||
packages/server/src/services/MustacheService.js
|
||||
packages/server/src/services/MustacheService.js.map
|
||||
packages/server/src/tools/db-migrate.d.ts
|
||||
packages/server/src/tools/db-migrate.js
|
||||
packages/server/src/tools/db-migrate.js.map
|
||||
packages/server/src/tools/dbTools.d.ts
|
||||
packages/server/src/tools/dbTools.js
|
||||
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/TransactionHandler.d.ts
|
||||
packages/server/src/utils/TransactionHandler.js
|
||||
packages/server/src/utils/TransactionHandler.js.map
|
||||
packages/server/src/utils/auth.d.ts
|
||||
packages/server/src/utils/auth.js
|
||||
packages/server/src/utils/auth.js.map
|
||||
packages/server/src/utils/base64.d.ts
|
||||
packages/server/src/utils/base64.js
|
||||
packages/server/src/utils/base64.js.map
|
||||
packages/server/src/utils/cache.d.ts
|
||||
packages/server/src/utils/cache.js
|
||||
packages/server/src/utils/cache.js.map
|
||||
packages/server/src/utils/defaultView.d.ts
|
||||
packages/server/src/utils/defaultView.js
|
||||
packages/server/src/utils/defaultView.js.map
|
||||
packages/server/src/utils/errors.d.ts
|
||||
packages/server/src/utils/errors.js
|
||||
packages/server/src/utils/errors.js.map
|
||||
packages/server/src/utils/htmlUtils.d.ts
|
||||
packages/server/src/utils/htmlUtils.js
|
||||
packages/server/src/utils/htmlUtils.js.map
|
||||
packages/server/src/utils/koaIf.d.ts
|
||||
packages/server/src/utils/koaIf.js
|
||||
packages/server/src/utils/koaIf.js.map
|
||||
packages/server/src/utils/requestUtils.d.ts
|
||||
packages/server/src/utils/requestUtils.js
|
||||
packages/server/src/utils/requestUtils.js.map
|
||||
packages/server/src/utils/routeUtils.d.ts
|
||||
packages/server/src/utils/routeUtils.js
|
||||
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/testUtils.d.ts
|
||||
packages/server/src/utils/testUtils.js
|
||||
packages/server/src/utils/testUtils.js.map
|
||||
packages/server/src/utils/testing/testRouters.d.ts
|
||||
packages/server/src/utils/testing/testRouters.js
|
||||
packages/server/src/utils/testing/testRouters.js.map
|
||||
packages/server/src/utils/time.d.ts
|
||||
packages/server/src/utils/time.js
|
||||
packages/server/src/utils/time.js.map
|
||||
packages/server/src/utils/types.d.ts
|
||||
packages/server/src/utils/types.js
|
||||
packages/server/src/utils/types.js.map
|
||||
packages/server/src/utils/uuidgen.d.ts
|
||||
packages/server/src/utils/uuidgen.js
|
||||
packages/server/src/utils/uuidgen.js.map
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
'root': true,
|
||||
'env': {
|
||||
'browser': true,
|
||||
'es6': true,
|
||||
@@ -34,6 +35,9 @@ module.exports = {
|
||||
'chrome': 'readonly',
|
||||
'browser': 'readonly',
|
||||
|
||||
// Server admin UI global variables
|
||||
'onDocumentReady': 'readonly',
|
||||
|
||||
'tinymce': 'readonly',
|
||||
},
|
||||
'parserOptions': {
|
||||
|
||||
217
.gitignore
vendored
217
.gitignore
vendored
@@ -48,6 +48,7 @@ TODO.md
|
||||
packages/tools/commit_hook.txt
|
||||
packages/tools/github_oauth_token.txt
|
||||
lerna-debug.log
|
||||
.env
|
||||
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
packages/app-cli/app/LinkSelector.d.ts
|
||||
@@ -878,12 +879,18 @@ packages/lib/InMemoryCache.js.map
|
||||
packages/lib/JoplinServerApi.d.ts
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/JoplinServerApi.js.map
|
||||
packages/lib/JoplinServerApi2.d.ts
|
||||
packages/lib/JoplinServerApi2.js
|
||||
packages/lib/JoplinServerApi2.js.map
|
||||
packages/lib/Logger.d.ts
|
||||
packages/lib/Logger.js
|
||||
packages/lib/Logger.js.map
|
||||
packages/lib/PoorManIntervals.d.ts
|
||||
packages/lib/PoorManIntervals.js
|
||||
packages/lib/PoorManIntervals.js.map
|
||||
packages/lib/SyncTargetJoplinServer.d.ts
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetJoplinServer.js.map
|
||||
packages/lib/Synchronizer.d.ts
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/Synchronizer.js.map
|
||||
@@ -905,6 +912,9 @@ packages/lib/errorUtils.js.map
|
||||
packages/lib/eventManager.d.ts
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/eventManager.js.map
|
||||
packages/lib/file-api-driver-joplinServer.d.ts
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-joplinServer.js.map
|
||||
packages/lib/fs-driver-base.d.ts
|
||||
packages/lib/fs-driver-base.js
|
||||
packages/lib/fs-driver-base.js.map
|
||||
@@ -1376,4 +1386,211 @@ packages/renderer/pathUtils.js.map
|
||||
packages/renderer/utils.d.ts
|
||||
packages/renderer/utils.js
|
||||
packages/renderer/utils.js.map
|
||||
packages/server/src/app.d.ts
|
||||
packages/server/src/app.js
|
||||
packages/server/src/app.js.map
|
||||
packages/server/src/config-base.d.ts
|
||||
packages/server/src/config-base.js
|
||||
packages/server/src/config-base.js.map
|
||||
packages/server/src/config-buildTypes.d.ts
|
||||
packages/server/src/config-buildTypes.js
|
||||
packages/server/src/config-buildTypes.js.map
|
||||
packages/server/src/config-dev.d.ts
|
||||
packages/server/src/config-dev.js
|
||||
packages/server/src/config-dev.js.map
|
||||
packages/server/src/config-prod.d.ts
|
||||
packages/server/src/config-prod.js
|
||||
packages/server/src/config-prod.js.map
|
||||
packages/server/src/config-tests.d.ts
|
||||
packages/server/src/config-tests.js
|
||||
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/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
|
||||
packages/server/src/migrations/20190913171451_create.d.ts
|
||||
packages/server/src/migrations/20190913171451_create.js
|
||||
packages/server/src/migrations/20190913171451_create.js.map
|
||||
packages/server/src/models/ApiClientModel.d.ts
|
||||
packages/server/src/models/ApiClientModel.js
|
||||
packages/server/src/models/ApiClientModel.js.map
|
||||
packages/server/src/models/BaseModel.d.ts
|
||||
packages/server/src/models/BaseModel.js
|
||||
packages/server/src/models/BaseModel.js.map
|
||||
packages/server/src/models/ChangeModel.d.ts
|
||||
packages/server/src/models/ChangeModel.js
|
||||
packages/server/src/models/ChangeModel.js.map
|
||||
packages/server/src/models/ChangeModel.test.d.ts
|
||||
packages/server/src/models/ChangeModel.test.js
|
||||
packages/server/src/models/ChangeModel.test.js.map
|
||||
packages/server/src/models/FileModel.d.ts
|
||||
packages/server/src/models/FileModel.js
|
||||
packages/server/src/models/FileModel.js.map
|
||||
packages/server/src/models/FileModel.test.d.ts
|
||||
packages/server/src/models/FileModel.test.js
|
||||
packages/server/src/models/FileModel.test.js.map
|
||||
packages/server/src/models/PermissionModel.d.ts
|
||||
packages/server/src/models/PermissionModel.js
|
||||
packages/server/src/models/PermissionModel.js.map
|
||||
packages/server/src/models/SessionModel.d.ts
|
||||
packages/server/src/models/SessionModel.js
|
||||
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/factory.d.ts
|
||||
packages/server/src/models/factory.js
|
||||
packages/server/src/models/factory.js.map
|
||||
packages/server/src/models/utils/pagination.d.ts
|
||||
packages/server/src/models/utils/pagination.js
|
||||
packages/server/src/models/utils/pagination.js.map
|
||||
packages/server/src/models/utils/pagination.test.d.ts
|
||||
packages/server/src/models/utils/pagination.test.js
|
||||
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/ping.d.ts
|
||||
packages/server/src/routes/api/ping.js
|
||||
packages/server/src/routes/api/ping.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/default.d.ts
|
||||
packages/server/src/routes/default.js
|
||||
packages/server/src/routes/default.js.map
|
||||
packages/server/src/routes/index/files.d.ts
|
||||
packages/server/src/routes/index/files.js
|
||||
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/login.d.ts
|
||||
packages/server/src/routes/index/login.js
|
||||
packages/server/src/routes/index/login.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/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/users.d.ts
|
||||
packages/server/src/routes/index/users.js
|
||||
packages/server/src/routes/index/users.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
|
||||
packages/server/src/routes/routes.d.ts
|
||||
packages/server/src/routes/routes.js
|
||||
packages/server/src/routes/routes.js.map
|
||||
packages/server/src/services/MustacheService.d.ts
|
||||
packages/server/src/services/MustacheService.js
|
||||
packages/server/src/services/MustacheService.js.map
|
||||
packages/server/src/tools/db-migrate.d.ts
|
||||
packages/server/src/tools/db-migrate.js
|
||||
packages/server/src/tools/db-migrate.js.map
|
||||
packages/server/src/tools/dbTools.d.ts
|
||||
packages/server/src/tools/dbTools.js
|
||||
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/TransactionHandler.d.ts
|
||||
packages/server/src/utils/TransactionHandler.js
|
||||
packages/server/src/utils/TransactionHandler.js.map
|
||||
packages/server/src/utils/auth.d.ts
|
||||
packages/server/src/utils/auth.js
|
||||
packages/server/src/utils/auth.js.map
|
||||
packages/server/src/utils/base64.d.ts
|
||||
packages/server/src/utils/base64.js
|
||||
packages/server/src/utils/base64.js.map
|
||||
packages/server/src/utils/cache.d.ts
|
||||
packages/server/src/utils/cache.js
|
||||
packages/server/src/utils/cache.js.map
|
||||
packages/server/src/utils/defaultView.d.ts
|
||||
packages/server/src/utils/defaultView.js
|
||||
packages/server/src/utils/defaultView.js.map
|
||||
packages/server/src/utils/errors.d.ts
|
||||
packages/server/src/utils/errors.js
|
||||
packages/server/src/utils/errors.js.map
|
||||
packages/server/src/utils/htmlUtils.d.ts
|
||||
packages/server/src/utils/htmlUtils.js
|
||||
packages/server/src/utils/htmlUtils.js.map
|
||||
packages/server/src/utils/koaIf.d.ts
|
||||
packages/server/src/utils/koaIf.js
|
||||
packages/server/src/utils/koaIf.js.map
|
||||
packages/server/src/utils/requestUtils.d.ts
|
||||
packages/server/src/utils/requestUtils.js
|
||||
packages/server/src/utils/requestUtils.js.map
|
||||
packages/server/src/utils/routeUtils.d.ts
|
||||
packages/server/src/utils/routeUtils.js
|
||||
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/testUtils.d.ts
|
||||
packages/server/src/utils/testUtils.js
|
||||
packages/server/src/utils/testUtils.js.map
|
||||
packages/server/src/utils/testing/testRouters.d.ts
|
||||
packages/server/src/utils/testing/testRouters.js
|
||||
packages/server/src/utils/testing/testRouters.js.map
|
||||
packages/server/src/utils/time.d.ts
|
||||
packages/server/src/utils/time.js
|
||||
packages/server/src/utils/time.js.map
|
||||
packages/server/src/utils/types.d.ts
|
||||
packages/server/src/utils/types.js
|
||||
packages/server/src/utils/types.js.map
|
||||
packages/server/src/utils/uuidgen.d.ts
|
||||
packages/server/src/utils/uuidgen.js
|
||||
packages/server/src/utils/uuidgen.js.map
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
|
||||
3
Dockerfile.db
Normal file
3
Dockerfile.db
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM postgres:13.1
|
||||
|
||||
EXPOSE 5432
|
||||
71
Dockerfile.server
Normal file
71
Dockerfile.server
Normal file
@@ -0,0 +1,71 @@
|
||||
# https://versatile.nl/blog/deploying-lerna-web-apps-with-docker
|
||||
|
||||
FROM node:12
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get --yes install vim
|
||||
|
||||
ARG user=joplin
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
USER $user
|
||||
|
||||
ENV NODE_ENV development
|
||||
|
||||
WORKDIR /home/$user
|
||||
|
||||
RUN mkdir /home/$user/logs
|
||||
|
||||
# To take advantage of the Docker cache, we first copy all the package.json
|
||||
# and package-lock.json files, as they rarely change? and then bootstrap
|
||||
# all the packages.
|
||||
#
|
||||
# Note that bootstrapping the packages will run all the postinstall
|
||||
# scripts, which means that for packages that have such scripts, we need to
|
||||
# copy all the files.
|
||||
#
|
||||
# We can't run boostrap with "--ignore-scripts" because that would
|
||||
# prevent certain sub-packages, such as sqlite3, from being built
|
||||
|
||||
COPY --chown=$user:$user 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 lerna.json .
|
||||
COPY --chown=$user:$user tsconfig.json .
|
||||
|
||||
# The following have postinstall scripts so we need to copy all the files.
|
||||
# Since they should rarely change this is not an issue
|
||||
|
||||
COPY --chown=$user:$user packages/turndown ./packages/turndown
|
||||
COPY --chown=$user:$user packages/turndown-plugin-gfm ./packages/turndown-plugin-gfm
|
||||
COPY --chown=$user:$user packages/fork-htmlparser2 ./packages/fork-htmlparser2
|
||||
|
||||
RUN ls -la /home/$user
|
||||
|
||||
# Then bootstrap only, without compiling the TypeScript files
|
||||
|
||||
RUN npm run bootstrap
|
||||
|
||||
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/server ./packages/server
|
||||
|
||||
# Finally build everything, in particular the TypeScript files.
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE ${JOPLIN_PORT}
|
||||
|
||||
CMD [ "npm", "--prefix", "packages/server", "start" ]
|
||||
@@ -20,9 +20,9 @@ Three types of applications are available: for the **desktop** (Windows, macOS a
|
||||
|
||||
Operating System | Download | Alternative
|
||||
---|---|---
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-Setup-1.4.19.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a> | Or get the <a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/JoplinPortable.exe'>Portable version</a><br><br>The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-1.4.19.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a> | -
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-1.4.19.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a> | The recommended way is to use the following installation script as it will handle the desktop icon too:<br><br> `wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh \| bash`
|
||||
Windows (32 and 64-bit) | <a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-Setup-1.5.11.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a> | Or get the <a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/JoplinPortable.exe'>Portable version</a><br><br>The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
|
||||
macOS | <a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-1.5.11.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a> | -
|
||||
Linux | <a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-1.5.11.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a> | The recommended way is to use the following installation script as it will handle the desktop icon too:<br><br> `wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh \| bash`
|
||||
|
||||
## Mobile applications
|
||||
|
||||
|
||||
28
docker-compose.server-dev.yml
Normal file
28
docker-compose.server-dev.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# For development, the easiest might be to only start the Postgres container and
|
||||
# run the app directly with `npm start`. Or use sqlite3.
|
||||
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.db
|
||||
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
|
||||
40
docker-compose.server.yml
Normal file
40
docker-compose.server.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
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
|
||||
File diff suppressed because one or more lines are too long
@@ -150,13 +150,21 @@
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>Registers a new content script. Unlike regular plugin code, which runs in a separate process, content scripts run within the main process code
|
||||
and thus allow improved performances and more customisations in specific cases. It can be used for example to load a Markdown or editor plugin.</p>
|
||||
<p>Registers a new content script. Unlike regular plugin code, which
|
||||
runs in a separate process, content scripts run within the main
|
||||
process code and thus allow improved performances and more
|
||||
customisations in specific cases. It can be used for example to load
|
||||
a Markdown or editor plugin.</p>
|
||||
</div>
|
||||
<p>Note that registering a content script in itself will do nothing - it will only be loaded in specific cases by the relevant app modules
|
||||
(eg. the Markdown renderer or the code editor). So it is not a way to inject and run arbitrary code in the app, which for safety and performance reasons is not supported.</p>
|
||||
<p><a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script">View the renderer demo plugin</a>
|
||||
<a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script">View the editor demo plugin</a></p>
|
||||
<p>Note that registering a content script in itself will do nothing -
|
||||
it will only be loaded in specific cases by the relevant app modules
|
||||
(eg. the Markdown renderer or the code editor). So it is not a way
|
||||
to inject and run arbitrary code in the app, which for safety and
|
||||
performance reasons is not supported.</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script">View the renderer demo plugin</a></li>
|
||||
<li><a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script">View the editor demo plugin</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<h4 class="tsd-parameters-title">Parameters</h4>
|
||||
<ul class="tsd-parameters">
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<p>Gets a global setting value, including app-specific settings and those set by other plugins.</p>
|
||||
</div>
|
||||
<p>The list of available settings is not documented yet, but can be found by looking at the source code:</p>
|
||||
<p><a href="https://github.com/laurent22/joplin/blob/3539a452a359162c461d2849829d2d42973eab50/packages/app-mobile/lib/models/Setting.ts#L142">https://github.com/laurent22/joplin/blob/3539a452a359162c461d2849829d2d42973eab50/packages/app-mobile/lib/models/Setting.ts#L142</a></p>
|
||||
<p><a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142">https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142</a></p>
|
||||
</div>
|
||||
<h4 class="tsd-parameters-title">Parameters</h4>
|
||||
<ul class="tsd-parameters">
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>Registers a new CodeMirror plugin, which should follow the template below.</p>
|
||||
<p>Registers a new CodeMirror plugin, which should follow the template
|
||||
below.</p>
|
||||
</div>
|
||||
<pre><code class="language-javascript"><span class="hljs-built_in">module</span>.exports = {
|
||||
<span class="hljs-attr">default</span>: <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">context</span>) </span>{
|
||||
@@ -98,8 +99,8 @@
|
||||
},
|
||||
<span class="hljs-attr">codeMirrorResources</span>: [],
|
||||
<span class="hljs-attr">codeMirrorOptions</span>: {
|
||||
<span class="hljs-comment">// ...</span>
|
||||
},
|
||||
<span class="hljs-comment">// ...</span>
|
||||
},
|
||||
<span class="hljs-attr">assets</span>: {
|
||||
<span class="hljs-comment">// ...</span>
|
||||
},
|
||||
@@ -107,19 +108,42 @@
|
||||
}
|
||||
}</code></pre>
|
||||
<ul>
|
||||
<li><p>The <code>context</code> argument is currently unused but could be used later on to provide access to your own plugin so that the content script and plugin can communicate.</p>
|
||||
<li><p>The <code>context</code> argument is currently unused but could be used later
|
||||
on to provide access to your own plugin so that the content script
|
||||
and plugin can communicate.</p>
|
||||
</li>
|
||||
<li><p>The <code>plugin</code> key is your CodeMirror plugin. This is where you can register new commands with CodeMirror or interact with the CodeMirror instance as needed.</p>
|
||||
<li><p>The <code>plugin</code> key is your CodeMirror plugin. This is where you can
|
||||
register new commands with CodeMirror or interact with the
|
||||
CodeMirror instance as needed.</p>
|
||||
</li>
|
||||
<li><p>The <code>codeMirrorResources</code> key is an array of CodeMirror resources that will be loaded and attached to the CodeMirror module. These are made up of addons, keymaps, and modes. For example, for a plugin that want's to enable clojure highlighting in code blocks. <code>codeMirrorResources</code> would be set to <code>['mode/clojure/clojure']</code>.</p>
|
||||
<li><p>The <code>codeMirrorResources</code> key is an array of CodeMirror resources
|
||||
that will be loaded and attached to the CodeMirror module. These
|
||||
are made up of addons, keymaps, and modes. For example, for a
|
||||
plugin that want's to enable clojure highlighting in code blocks.
|
||||
<code>codeMirrorResources</code> would be set to <code>['mode/clojure/clojure']</code>.</p>
|
||||
</li>
|
||||
<li><p>The <code>codeMirrorOptions</code> key contains all the <a href="https://codemirror.net/doc/manual.html#config">CodeMirror</a> options that will be set or changed by this plugin. New options can alse be declared via <a href="https://codemirror.net/doc/manual.html#defineOption"><code>CodeMirror.defineOption</code></a>, and then have their value set here. For example, a plugin that enables line numbers would set <code>codeMirrorOptions</code> to <code>{'lineNumbers': true}</code>.</p>
|
||||
<li><p>The <code>codeMirrorOptions</code> key contains all the
|
||||
<a href="https://codemirror.net/doc/manual.html#config">CodeMirror</a>
|
||||
options that will be set or changed by this plugin. New options
|
||||
can alse be declared via
|
||||
<a href="https://codemirror.net/doc/manual.html#defineOption"><code>CodeMirror.defineOption</code></a>,
|
||||
and then have their value set here. For example, a plugin that
|
||||
enables line numbers would set <code>codeMirrorOptions</code> to
|
||||
<code>{'lineNumbers': true}</code>.</p>
|
||||
</li>
|
||||
<li><p>Using the <strong>optional</strong> <code>assets</code> key you may specify <strong>only</strong> CSS assets that should be loaded in the rendered HTML document. Check for example the Joplin <a href="https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/lib/joplin-renderer/MdToHtml/rules/mermaid.ts">Mermaid plugin</a> to see how the data should be structured.</p>
|
||||
<li><p>Using the <strong>optional</strong> <code>assets</code> key you may specify <strong>only</strong> CSS
|
||||
assets that should be loaded in the rendered HTML document. Check
|
||||
for example the Joplin [Mermaid
|
||||
plugin](<a href="https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts">https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts</a>)
|
||||
to see how the data should be structured.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>One of the <code>plugin</code>, <code>codeMirrorResources</code>, or <code>codeMirrorOptions</code> keys must be provided for the plugin to be valid. Having multiple or all provided is also okay.</p>
|
||||
<p>See the <a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script">demo plugin</a> for an example of all these keys being used in one plugin.</p>
|
||||
<p>One of the <code>plugin</code>, <code>codeMirrorResources</code>, or <code>codeMirrorOptions</code>
|
||||
keys must be provided for the plugin to be valid. Having multiple or
|
||||
all provided is also okay.</p>
|
||||
<p>See the <a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script">demo
|
||||
plugin</a>
|
||||
for an example of all these keys being used in one plugin.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
@@ -130,7 +154,8 @@
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>Registers a new Markdown-It plugin, which should follow the template below.</p>
|
||||
<p>Registers a new Markdown-It plugin, which should follow the template
|
||||
below.</p>
|
||||
</div>
|
||||
<pre><code class="language-javascript"><span class="hljs-built_in">module</span>.exports = {
|
||||
<span class="hljs-attr">default</span>: <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">context</span>) </span>{
|
||||
@@ -144,15 +169,50 @@
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<p>See <a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script">the
|
||||
demo</a>
|
||||
for a simple Markdown-it plugin example.</p>
|
||||
<a href="#exported-members" id="exported-members" style="color: inherit; text-decoration: none;">
|
||||
<h2>Exported members</h2>
|
||||
</a>
|
||||
<ul>
|
||||
<li><p>The <code>context</code> argument is currently unused but could be used later on to provide access to your own plugin so that the content script and plugin can communicate.</p>
|
||||
<li><p>The <code>context</code> argument is currently unused but could be used later
|
||||
on to provide access to your own plugin so that the content script
|
||||
and plugin can communicate.</p>
|
||||
</li>
|
||||
<li><p>The <strong>required</strong> <code>plugin</code> key is the actual Markdown-It plugin - check the <a href="https://github.com/markdown-it/markdown-it">official doc</a> for more information. The <code>options</code> parameter is of type <a href="https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/lib/joplin-renderer/MdToHtml.ts">RuleOptions</a>, which contains a number of options, mostly useful for Joplin's internal code.</p>
|
||||
<li><p>The <strong>required</strong> <code>plugin</code> key is the actual Markdown-It plugin -
|
||||
check the [official
|
||||
doc](<a href="https://github.com/markdown-it/markdown-it">https://github.com/markdown-it/markdown-it</a>) for more
|
||||
information. The <code>options</code> parameter is of type
|
||||
<a href="https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts">RuleOptions</a>,
|
||||
which contains a number of options, mostly useful for Joplin's
|
||||
internal code.</p>
|
||||
</li>
|
||||
<li><p>Using the <strong>optional</strong> <code>assets</code> key you may specify assets such as JS or CSS that should be loaded in the rendered HTML document. Check for example the Joplin <a href="https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/lib/joplin-renderer/MdToHtml/rules/mermaid.ts">Mermaid plugin</a> to see how the data should be structured.</p>
|
||||
<li><p>Using the <strong>optional</strong> <code>assets</code> key you may specify assets such as
|
||||
JS or CSS that should be loaded in the rendered HTML document.
|
||||
Check for example the Joplin [Mermaid
|
||||
plugin](<a href="https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts">https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts</a>)
|
||||
to see how the data should be structured.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>To include a regular Markdown-It plugin, that doesn't make use of any Joplin-specific features, you would simply create a file such as this:</p>
|
||||
<a href="#passing-messages-from-the-content-script-to-your-plugin" id="passing-messages-from-the-content-script-to-your-plugin" style="color: inherit; text-decoration: none;">
|
||||
<h2>Passing messages from the content script to your plugin</h2>
|
||||
</a>
|
||||
<p>The application provides the following function to allow executing
|
||||
commands from the rendered HTML code:</p>
|
||||
<p><code>webviewApi.executeCommand(commandName, ...args)</code></p>
|
||||
<p>So you can use this mechanism to pass messages from the note viewer
|
||||
to your own plugin. To do so you would define a command, using
|
||||
<code>joplin.commands.register</code>, then you would call this command using
|
||||
the <code>webviewApi</code> object. See again <a href="https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script">the
|
||||
demo</a>
|
||||
to see how this can be done.</p>
|
||||
<a href="#registering-an-existing-markdown-it-plugin" id="registering-an-existing-markdown-it-plugin" style="color: inherit; text-decoration: none;">
|
||||
<h2>Registering an existing Markdown-it plugin</h2>
|
||||
</a>
|
||||
<p>To include a regular Markdown-It plugin, that doesn't make use of
|
||||
any Joplin-specific features, you would simply create a file such as
|
||||
this:</p>
|
||||
<pre><code class="language-javascript"><span class="hljs-built_in">module</span>.exports = {
|
||||
<span class="hljs-attr">default</span>: <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">context</span>) </span>{
|
||||
<span class="hljs-keyword">return</span> {
|
||||
|
||||
@@ -75,9 +75,11 @@
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#edit" class="tsd-kind-icon">Edit</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#editorcontextmenu" class="tsd-kind-icon">Editor<wbr>Context<wbr>Menu</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#file" class="tsd-kind-icon">File</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#foldercontextmenu" class="tsd-kind-icon">Folder<wbr>Context<wbr>Menu</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#help" class="tsd-kind-icon">Help</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#note" class="tsd-kind-icon">Note</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#notelistcontextmenu" class="tsd-kind-icon">Note<wbr>List<wbr>Context<wbr>Menu</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#tagcontextmenu" class="tsd-kind-icon">Tag<wbr>Context<wbr>Menu</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#tools" class="tsd-kind-icon">Tools</a></li>
|
||||
<li class="tsd-kind-enum-member tsd-parent-kind-enum"><a href="menuitemlocation.html#view" class="tsd-kind-icon">View</a></li>
|
||||
</ul>
|
||||
@@ -122,6 +124,22 @@
|
||||
<aside class="tsd-sources">
|
||||
</aside>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a name="foldercontextmenu" class="tsd-anchor"></a>
|
||||
<h3>Folder<wbr>Context<wbr>Menu</h3>
|
||||
<div class="tsd-signature tsd-kind-icon">Folder<wbr>Context<wbr>Menu<span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol"> = "folderContextMenu"</span></div>
|
||||
<aside class="tsd-sources">
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>When a command is called from a folder context menu, the
|
||||
command will receive the following arguments:</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li><code>folderId:string</code>: ID of the folder that was right-clicked on</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a name="help" class="tsd-anchor"></a>
|
||||
<h3>Help</h3>
|
||||
@@ -142,6 +160,31 @@
|
||||
<div class="tsd-signature tsd-kind-icon">Note<wbr>List<wbr>Context<wbr>Menu<span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol"> = "noteListContextMenu"</span></div>
|
||||
<aside class="tsd-sources">
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>When a command is called from the note list context menu, the
|
||||
command will receive the following arguments:</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li><code>noteIds:string[]</code>: IDs of the notes that were right-clicked on.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a name="tagcontextmenu" class="tsd-anchor"></a>
|
||||
<h3>Tag<wbr>Context<wbr>Menu</h3>
|
||||
<div class="tsd-signature tsd-kind-icon">Tag<wbr>Context<wbr>Menu<span class="tsd-signature-symbol">:</span> <span class="tsd-signature-symbol"> = "tagContextMenu"</span></div>
|
||||
<aside class="tsd-sources">
|
||||
</aside>
|
||||
<div class="tsd-comment tsd-typography">
|
||||
<div class="lead">
|
||||
<p>When a command is called from a tag context menu, the
|
||||
command will receive the following arguments:</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li><code>tagId:string</code>: ID of the tag that was right-clicked on</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a name="tools" class="tsd-anchor"></a>
|
||||
@@ -188,6 +231,9 @@
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#file" class="tsd-kind-icon">File</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#foldercontextmenu" class="tsd-kind-icon">Folder<wbr>Context<wbr>Menu</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#help" class="tsd-kind-icon">Help</a>
|
||||
</li>
|
||||
@@ -197,6 +243,9 @@
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#notelistcontextmenu" class="tsd-kind-icon">Note<wbr>List<wbr>Context<wbr>Menu</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#tagcontextmenu" class="tsd-kind-icon">Tag<wbr>Context<wbr>Menu</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-enum-member tsd-parent-kind-enum">
|
||||
<a href="menuitemlocation.html#tools" class="tsd-kind-icon">Tools</a>
|
||||
</li>
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
<li class="tsd-kind-variable"><a href="globals.html#logger" class="tsd-kind-icon">logger</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="tsd-index-section ">
|
||||
<h3>Functions</h3>
|
||||
<ul class="tsd-index-list">
|
||||
<li class="tsd-kind-function"><a href="globals.html#iscontextmenuitemlocation" class="tsd-kind-icon">is<wbr>Context<wbr>Menu<wbr>Item<wbr>Location</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -187,9 +193,11 @@
|
||||
<div class="lead">
|
||||
<p>An array of at least one element and at most three elements.</p>
|
||||
</div>
|
||||
<p>[0]: Resource name (eg. "notes", "folders", "tags", etc.)
|
||||
[1]: (Optional) Resource ID.
|
||||
[2]: (Optional) Resource link.</p>
|
||||
<ul>
|
||||
<li><strong>[0]</strong>: Resource name (eg. "notes", "folders", "tags", etc.)</li>
|
||||
<li><strong>[1]</strong>: (Optional) Resource ID.</li>
|
||||
<li><strong>[2]</strong>: (Optional) Resource link.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tsd-panel tsd-member tsd-kind-type-alias">
|
||||
@@ -242,6 +250,33 @@
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
<section class="tsd-panel-group tsd-member-group ">
|
||||
<h2>Functions</h2>
|
||||
<section class="tsd-panel tsd-member tsd-kind-function">
|
||||
<a name="iscontextmenuitemlocation" class="tsd-anchor"></a>
|
||||
<h3>is<wbr>Context<wbr>Menu<wbr>Item<wbr>Location</h3>
|
||||
<ul class="tsd-signatures tsd-kind-function">
|
||||
<li class="tsd-signature tsd-kind-icon">is<wbr>Context<wbr>Menu<wbr>Item<wbr>Location<span class="tsd-signature-symbol">(</span>location<span class="tsd-signature-symbol">: </span><a href="enums/menuitemlocation.html" class="tsd-signature-type">MenuItemLocation</a><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">: </span><span class="tsd-signature-type">boolean</span></li>
|
||||
</ul>
|
||||
<ul class="tsd-descriptions">
|
||||
<li class="tsd-description">
|
||||
<aside class="tsd-sources">
|
||||
</aside>
|
||||
<h4 class="tsd-parameters-title">Parameters</h4>
|
||||
<ul class="tsd-parameters">
|
||||
<li>
|
||||
<h5>location: <a href="enums/menuitemlocation.html" class="tsd-signature-type">MenuItemLocation</a></h5>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- JOPLINCHANGE
|
||||
<h4 class="tsd-returns-title">Returns <span class="tsd-signature-type">boolean</span></h4>
|
||||
|
||||
|
||||
-->
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-4 col-menu menu-sticky-wrap menu-highlight">
|
||||
<!--
|
||||
@@ -381,6 +416,9 @@
|
||||
<li class=" tsd-kind-type-alias">
|
||||
<a href="globals.html#viewhandle" class="tsd-kind-icon">ViewHandle</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-function">
|
||||
<a href="globals.html#iscontextmenuitemlocation" class="tsd-kind-icon">isContextMenuItemLocation</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
<li class=" tsd-kind-type-alias">
|
||||
<a href="globals.html#viewhandle" class="tsd-kind-icon">ViewHandle</a>
|
||||
</li>
|
||||
<li class=" tsd-kind-function">
|
||||
<a href="globals.html#iscontextmenuitemlocation" class="tsd-kind-icon">isContextMenuItemLocation</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<td>"oneNoteSelected && !inConflictFolder"</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<p>Currently the supported context variables aren't documented, but you can <a href="https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/lib/services/commands/stateToWhenClauseContext.ts">find the list here</a>.</p>
|
||||
<p>Currently the supported context variables aren't documented, but you can <a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts">find the list here</a>.</p>
|
||||
<p>Note: Commands are enabled by default unless you use this property.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -400,6 +400,46 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog.md
|
||||
|
||||
<div class="main">
|
||||
<h1>Joplin changelog<a name="joplin-changelog" href="#joplin-changelog" class="heading-anchor">🔗</a></h1>
|
||||
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.11">v1.5.11</a> - 2020-12-27T19:54:07Z<a name="v1-5-11-https-github-com-laurent22-joplin-releases-tag-v1-5-11-2020-12-27t19-54-07z" href="#v1-5-11-https-github-com-laurent22-joplin-releases-tag-v1-5-11-2020-12-27t19-54-07z" class="heading-anchor">🔗</a></h2>
|
||||
<ul>
|
||||
<li>New: Add support for media players (video, audio and PDF)</li>
|
||||
<li>New: Add table captions when importing ENEX files</li>
|
||||
<li>New: Added doc about Rich Text editor and added way to dismiss warning banner</li>
|
||||
<li>New: MacOS: Notarize application</li>
|
||||
<li>New: Plugins: Add support for content script asset files, for Markdown-it plugins</li>
|
||||
<li>New: Plugins: Add support for context menu items on notebooks and tags</li>
|
||||
<li>New: Plugins: Add support for workspace.onSyncStart event</li>
|
||||
<li>New: Plugins: Added a way to execute commands from Markdown-it content scripts</li>
|
||||
<li>Fixed: Fix End key behavior with Codemirror spellcheck (<a href="https://github.com/laurent22/joplin/issues/4215">#4215</a> by Caleb John)</li>
|
||||
<li>Fixed: Fixed basic search when executing a query in Chinese (<a href="https://github.com/laurent22/joplin/issues/4034">#4034</a> by Naveen M V)</li>
|
||||
<li>Fixed: Fixed context menu when the UI is zoomed in or out (<a href="https://github.com/laurent22/joplin/issues/4201">#4201</a>)</li>
|
||||
<li>Fixed: Fixed importing certain code blocks from ENEX</li>
|
||||
<li>Fixed: Fixed importing ENEX files that contain empty resources</li>
|
||||
<li>Fixed: Fixed importing ENEX files that contain resources with invalid mime type</li>
|
||||
<li>Fixed: Fixed issue when searching for text that contains diacritic (<a href="https://github.com/laurent22/joplin/issues/4152">#4152</a>) (<a href="https://github.com/laurent22/joplin/issues/4025">#4025</a> by Roman Musin)</li>
|
||||
<li>Fixed: Fixed issue with attachment paths being invalid when user has spaces in home directory path.</li>
|
||||
<li>Fixed: Fixed issue with note not being saved when a column is added or remove from Rich Text editor</li>
|
||||
<li>Fixed: Fixed issues when importing hidden tables within hidden sections in Enex files</li>
|
||||
<li>Fixed: Fixed numbered list bug in markdown editor (<a href="https://github.com/laurent22/joplin/issues/4116">#4116</a>) (<a href="https://github.com/laurent22/joplin/issues/3917">#3917</a> by <a href="https://github.com/MichBoi">@MichBoi</a>)</li>
|
||||
<li>Fixed: Fixed potential crash when watching note files or resources</li>
|
||||
<li>Fixed: Fixed title input field width on small windows</li>
|
||||
<li>Fixed: Focus editor after pressing toolbar buttons (<a href="https://github.com/laurent22/joplin/issues/4037">#4037</a>) (<a href="https://github.com/laurent22/joplin/issues/4036">#4036</a> by <a href="https://github.com/CalebJohn">@CalebJohn</a>)</li>
|
||||
<li>Fixed: Plugins: Fixed disabling plugin files that start with "_"</li>
|
||||
<li>Fixed: Prevent double paste when using Shift+Ctrl+V (<a href="https://github.com/laurent22/joplin/issues/4243">#4243</a>)</li>
|
||||
<li>Fixed: Prevents crash when invalid spell checker language is selected, and provide fallback for invalid language codes (<a href="https://github.com/laurent22/joplin/issues/4146">#4146</a>)</li>
|
||||
<li>Fixed: Register Markdown editor commands with the Keyboard Shortcut editor (<a href="https://github.com/laurent22/joplin/issues/4136">#4136</a>) (<a href="https://github.com/laurent22/joplin/issues/4130">#4130</a> by Caleb John)</li>
|
||||
<li>Improved: Display Katex parsing errors</li>
|
||||
<li>Improved: Improved warning banner colors</li>
|
||||
<li>Improved: Plugins: Commands would not show up in keymap editor when no shortcut was associated with them</li>
|
||||
<li>Improved: Plugins: Improved note change event handling.</li>
|
||||
<li>Improved: Removed warning for Markdown editor spell checking</li>
|
||||
<li>Improved: Restrict auto-detection of links, and added option to toggle linkify (<a href="https://github.com/laurent22/joplin/issues/4205">#4205</a>)</li>
|
||||
<li>Improved: Rich Text: Do not converts to markdown links URLs that would be linkified</li>
|
||||
<li>Improved: Translation: Update zh_CN (<a href="https://github.com/laurent22/joplin/issues/4195">#4195</a> by Zhang YANG)</li>
|
||||
<li>Improved: Update macOS icon for macOS Big Sur</li>
|
||||
<li>Improved: Update Mermaid: 8.8.1 -> 8.8.4 (<a href="https://github.com/laurent22/joplin/issues/4193">#4193</a> by Helmut K. C. Tessarek)</li>
|
||||
<li>Improved: Use plugins whenever printing or exporting notes</li>
|
||||
</ul>
|
||||
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.19">v1.4.19</a> - 2020-12-01T11:11:16Z<a name="v1-4-19-https-github-com-laurent22-joplin-releases-tag-v1-4-19-2020-12-01t11-11-16z" href="#v1-4-19-https-github-com-laurent22-joplin-releases-tag-v1-4-19-2020-12-01t11-11-16z" class="heading-anchor">🔗</a></h2>
|
||||
<ul>
|
||||
<li>Improved: Disable soft-break by default in Markdown rendering</li>
|
||||
|
||||
@@ -400,6 +400,22 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog_cli.md
|
||||
|
||||
<div class="main">
|
||||
<h1>Joplin terminal app changelog<a name="joplin-terminal-app-changelog" href="#joplin-terminal-app-changelog" class="heading-anchor">🔗</a></h1>
|
||||
<h2><a href="https://github.com/laurent22/joplin/releases/tag/cli-v1.5.1">cli-v1.5.1</a> - 2020-12-26T00:46:31Z<a name="cli-v1-5-1-https-github-com-laurent22-joplin-releases-tag-cli-v1-5-1-2020-12-26t00-46-31z" href="#cli-v1-5-1-https-github-com-laurent22-joplin-releases-tag-cli-v1-5-1-2020-12-26t00-46-31z" class="heading-anchor">🔗</a></h2>
|
||||
<ul>
|
||||
<li>New: Add table captions when importing ENEX files</li>
|
||||
<li>Improved: Allow exporting conflict notes (#4095)</li>
|
||||
<li>Improved: Allow lowercase filters when doing search</li>
|
||||
<li>Improved: Improved error handling when importing ENEX files</li>
|
||||
<li>Improved: Partially reverts #3975 (link rendering)</li>
|
||||
<li>Fixed: Fix sorting by title in a case insensitive way</li>
|
||||
<li>Fixed: Fixed basic search when executing a query in Chinese (#4034 by Naveen M V)</li>
|
||||
<li>Fixed: Fixed importing ENEX files that contain empty resources</li>
|
||||
<li>Fixed: Fixed importing ENEX files that contain resources with invalid mime type</li>
|
||||
<li>Fixed: Fixed importing certain ENEX files that contain invalid dates</li>
|
||||
<li>Fixed: Fixed importing certain code blocks from ENEX</li>
|
||||
<li>Fixed: Fixed issue when searching for text that contains diacritic (#4152) (#4025 by Roman Musin)</li>
|
||||
<li>Fixed: Fixed issues when importing hidden tables within hidden sections in Enex files</li>
|
||||
</ul>
|
||||
<h2><a href="https://github.com/laurent22/joplin/releases/tag/cli-v1.4.9">cli-v1.4.9</a> - 2020-11-26T15:00:37Z<a name="cli-v1-4-9-https-github-com-laurent22-joplin-releases-tag-cli-v1-4-9-2020-11-26t15-00-37z" href="#cli-v1-4-9-https-github-com-laurent22-joplin-releases-tag-cli-v1-4-9-2020-11-26t15-00-37z" class="heading-anchor">🔗</a></h2>
|
||||
<ul>
|
||||
<li>Improved: Allow exporting conflict notes (#4095)</li>
|
||||
|
||||
@@ -411,6 +411,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/faq.md
|
||||
</ul>
|
||||
<p>Now try to install again and it should work.</p>
|
||||
<p>More info there: <a href="https://github.com/electron-userland/electron-builder/issues/4057">https://github.com/electron-userland/electron-builder/issues/4057</a></p>
|
||||
<h2>How can I pass arguments to the Linux installation script?<a name="how-can-i-pass-arguments-to-the-linux-installation-script" href="#how-can-i-pass-arguments-to-the-linux-installation-script" class="heading-anchor">🔗</a></h2>
|
||||
<p>You can pass <a href="https://github.com/laurent22/joplin/blob/dev/Joplin_install_and_update.sh#L37">arguments</a> to the installation script by using this command.</p>
|
||||
<p><code>wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh \| bash -s -- --argument1 --argument2</code></p>
|
||||
<h2>How can I edit my note in an external text editor?<a name="how-can-i-edit-my-note-in-an-external-text-editor" href="#how-can-i-edit-my-note-in-an-external-text-editor" class="heading-anchor">🔗</a></h2>
|
||||
<p>The editor command (may include arguments) defines which editor will be used to open a note. If none is provided it will try to auto-detect the default editor. If this does nothing or you want to change it for Joplin, you need to configure it in the Preferences -> Text editor command.</p>
|
||||
<p>Some example configurations are: (comments after #)</p>
|
||||
|
||||
@@ -420,17 +420,17 @@ https://github.com/laurent22/joplin/blob/dev/README.md
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Windows (32 and 64-bit)</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-Setup-1.4.19.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a></td>
|
||||
<td>Or get the <a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/JoplinPortable.exe'>Portable version</a><br><br>The <a href="https://en.wikipedia.org/wiki/Portable_application">portable application</a> allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-Setup-1.5.11.exe'><img alt='Get it on Windows' width="134px" src='https://joplinapp.org/images/BadgeWindows.png'/></a></td>
|
||||
<td>Or get the <a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/JoplinPortable.exe'>Portable version</a><br><br>The <a href="https://en.wikipedia.org/wiki/Portable_application">portable application</a> allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>macOS</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-1.4.19.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-1.5.11.dmg'><img alt='Get it on macOS' width="134px" src='https://joplinapp.org/images/BadgeMacOS.png'/></a></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linux</td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.4.19/Joplin-1.4.19.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a></td>
|
||||
<td><a href='https://github.com/laurent22/joplin/releases/download/v1.5.11/Joplin-1.5.11.AppImage'><img alt='Get it on Linux' width="134px" src='https://joplinapp.org/images/BadgeLinux.png'/></a></td>
|
||||
<td>The recommended way is to use the following installation script as it will handle the desktop icon too:<br><br> <code>wget -O - https://raw.githubusercontent.com/laurent22/joplin/dev/Joplin_install_and_update.sh | bash</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -400,6 +400,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/nextcloud_app.md
|
||||
|
||||
<div class="main">
|
||||
<h1>Joplin Web API for Nextcloud<a name="joplin-web-api-for-nextcloud" href="#joplin-web-api-for-nextcloud" class="heading-anchor">🔗</a></h1>
|
||||
<hr>
|
||||
<p><strong>IMPORTANT: THIS APPLICATION IS DEPRECATED AND WILL NO LONGER BE SUPPORTED FROM JOPLIN 1.6.x</strong></p>
|
||||
<p>It is deprecated in favour of <a href="https://discourse.joplinapp.org/t/joplin-web-api-for-nextcloud/4491/72?u=laurent">Joplin Server</a>, so if you are relying on it please do not upgrade to Joplin 1.6.x till you are ready to migrate to Joplin Server or some other alternative.</p>
|
||||
<hr>
|
||||
<p><strong>This is a beta feature, not yet completed. More info coming soon!</strong></p>
|
||||
<p>The app can be downloaded from there: <a href="https://apps.nextcloud.com/apps/joplin">https://apps.nextcloud.com/apps/joplin</a></p>
|
||||
<p>The Joplin Web API for Nextcloud is a helper application that enables certain features that are not possible otherwise. In particular:</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,7 @@
|
||||
"**/_vieux/": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/*.base64": true,
|
||||
"**/*~": true,
|
||||
"**/*.bundle.js": true,
|
||||
"**/*.eot": true,
|
||||
"**/*.icns": true,
|
||||
@@ -309,6 +310,10 @@
|
||||
"packages/renderer/MdToHtml/rules/sanitize_html.js": true,
|
||||
"packages/app-mobile/lib/rnInjectedJs/": true,
|
||||
"packages/app-mobile/lib/sql-extensions/spellfix.so": true,
|
||||
"packages/server/dist/": true,
|
||||
"packages/server/db-*.sqlite": true,
|
||||
"packages/server/test.pid": true,
|
||||
"packages/server/temp": true,
|
||||
"packages/generator-joplin/generators/app/templates/api/": true,
|
||||
"packages/app-mobile/node_modules/": true,
|
||||
"phpunit.xml": true,
|
||||
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -289,6 +289,75 @@
|
||||
"dedent": "^0.7.0",
|
||||
"npmlog": "^4.1.2",
|
||||
"yargs": "^14.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"dev": true
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
"strip-ansi": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
|
||||
"dev": true
|
||||
},
|
||||
"yargs": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz",
|
||||
"integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cliui": "^5.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^3.0.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^3.0.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^15.0.1"
|
||||
}
|
||||
},
|
||||
"yargs-parser": {
|
||||
"version": "15.0.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz",
|
||||
"integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@lerna/collect-uncommitted": {
|
||||
@@ -2183,6 +2252,14 @@
|
||||
"ssri": "^6.0.1",
|
||||
"unique-filename": "^1.1.1",
|
||||
"y18n": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"cache-base": {
|
||||
@@ -10578,80 +10655,11 @@
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true
|
||||
},
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
|
||||
"dev": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true
|
||||
},
|
||||
"yargs": {
|
||||
"version": "14.2.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz",
|
||||
"integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cliui": "^5.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^3.0.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^3.0.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^15.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
|
||||
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
|
||||
"dev": true
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
"strip-ansi": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"yargs-parser": {
|
||||
"version": "15.0.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz",
|
||||
"integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,16 @@
|
||||
"buildTranslationsNoTsc": "node packages/tools/build-translation.js",
|
||||
"buildWebsite": "npm run buildApiDoc && node ./packages/tools/build-website.js && npm run buildPluginDoc",
|
||||
"clean": "lerna clean -y && lerna run clean",
|
||||
"circularDependencyCheck": "npx madge --warning --circular --extensions js ./",
|
||||
"generateDatabaseTypes": "node packages/tools/generate-database-types",
|
||||
"linkChecker": "linkchecker https://joplinapp.org",
|
||||
"linter-ci": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter-precommit": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"postinstall": "lerna bootstrap --no-ci && npm run tsc",
|
||||
"bootstrap": "lerna bootstrap --no-ci",
|
||||
"bootstrapIgnoreScripts": "lerna bootstrap --ignore-scripts --no-ci",
|
||||
"postinstall": "npm run bootstrap --no-ci && npm run build",
|
||||
"build": "lerna run build && npm run tsc",
|
||||
"publishAll": "git pull && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
|
||||
"releaseAndroid": "node packages/tools/release-android.js",
|
||||
"releaseCli": "node packages/tools/release-cli.js",
|
||||
@@ -39,7 +43,8 @@
|
||||
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
|
||||
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
|
||||
"watch": "lerna run watch --stream --parallel",
|
||||
"i": "lerna add --no-bootstrap --scope"
|
||||
"i": "lerna add --no-bootstrap --scope",
|
||||
"server-start-dev": "docker-compose --file docker-compose.server-dev.yml up"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"bin": {
|
||||
"joplin": "./main.js"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { allNotesFolders, remoteNotesAndFolders, localNotesFoldersSameAsRemote } from './test-utils-synchronizer';
|
||||
|
||||
const { syncTargetName, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } = require('./test-utils.js');
|
||||
const { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } = require('./test-utils.js');
|
||||
const Folder = require('@joplin/lib/models/Folder.js');
|
||||
const Note = require('@joplin/lib/models/Note.js');
|
||||
const BaseItem = require('@joplin/lib/models/BaseItem.js');
|
||||
@@ -16,6 +16,10 @@ describe('Synchronizer.basics', function() {
|
||||
done();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllCleanUp();
|
||||
});
|
||||
|
||||
it('should create remote items', (async () => {
|
||||
const folder = await Folder.save({ title: 'folder1' });
|
||||
await Note.save({ title: 'un', parent_id: folder.id });
|
||||
@@ -123,10 +127,6 @@ describe('Synchronizer.basics', function() {
|
||||
}));
|
||||
|
||||
it('should delete local notes', (async () => {
|
||||
// For these tests we pass the context around for each user. This is to make sure that the "deletedItemsProcessed"
|
||||
// property of the basicDelta() function is cleared properly at the end of a sync operation. If it is not cleared
|
||||
// it means items will no longer be deleted locally via sync.
|
||||
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
||||
const note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
|
||||
|
||||
@@ -15,6 +15,7 @@ import KeychainServiceDriver from '@joplin/lib/services/keychain/KeychainService
|
||||
import KeychainServiceDriverDummy from '@joplin/lib/services/keychain/KeychainServiceDriver.dummy';
|
||||
import PluginRunner from '../app/services/plugins/PluginRunner';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import FileApiDriverJoplinServer from '@joplin/lib/file-api-driver-joplinServer';
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const { JoplinDatabase } = require('@joplin/lib/joplin-database.js');
|
||||
@@ -43,12 +44,14 @@ const SyncTargetOneDrive = require('@joplin/lib/SyncTargetOneDrive.js');
|
||||
const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
|
||||
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
|
||||
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
|
||||
const SyncTargetJoplinServer = require('@joplin/lib/SyncTargetJoplinServer').default;
|
||||
const EncryptionService = require('@joplin/lib/services/EncryptionService.js');
|
||||
const DecryptionWorker = require('@joplin/lib/services/DecryptionWorker.js');
|
||||
const RevisionService = require('@joplin/lib/services/RevisionService.js');
|
||||
const ResourceFetcher = require('@joplin/lib/services/ResourceFetcher.js');
|
||||
const WebDavApi = require('@joplin/lib/WebDavApi');
|
||||
const DropboxApi = require('@joplin/lib/DropboxApi');
|
||||
const JoplinServerApi = require('@joplin/lib/JoplinServerApi2').default;
|
||||
const { OneDriveApi } = require('@joplin/lib/onedrive-api');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const md5 = require('md5');
|
||||
@@ -116,6 +119,7 @@ SyncTargetRegistry.addClass(SyncTargetOneDrive);
|
||||
SyncTargetRegistry.addClass(SyncTargetNextcloud);
|
||||
SyncTargetRegistry.addClass(SyncTargetDropbox);
|
||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
|
||||
let syncTargetName_ = '';
|
||||
let syncTargetId_: number = null;
|
||||
@@ -132,7 +136,7 @@ function setSyncTargetName(name: string) {
|
||||
syncTargetName_ = name;
|
||||
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
||||
sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3'].includes(syncTargetName_);
|
||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer'].includes(syncTargetName_);
|
||||
synchronizers_ = [];
|
||||
return previousName;
|
||||
}
|
||||
@@ -142,6 +146,7 @@ setSyncTargetName('memory');
|
||||
// setSyncTargetName('dropbox');
|
||||
// setSyncTargetName('onedrive');
|
||||
// setSyncTargetName('amazon_s3');
|
||||
// setSyncTargetName('joplinServer');
|
||||
|
||||
// console.info(`Testing with sync target: ${syncTargetName_}`);
|
||||
|
||||
@@ -214,6 +219,16 @@ async function afterEachCleanUp() {
|
||||
KeymapService.destroyInstance();
|
||||
}
|
||||
|
||||
async function afterAllCleanUp() {
|
||||
if (fileApi()) {
|
||||
try {
|
||||
await fileApi().clearRoot();
|
||||
} catch (error) {
|
||||
console.warn('Could not clear sync target root:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function switchClient(id: number, options: any = null) {
|
||||
options = Object.assign({}, { keychainEnabled: false }, options);
|
||||
|
||||
@@ -346,7 +361,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
|
||||
if (!synchronizers_[id]) {
|
||||
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId_);
|
||||
const syncTarget = new SyncTargetClass(db(id));
|
||||
await initFileApi();
|
||||
await initFileApi(suiteName_);
|
||||
syncTarget.setFileApi(fileApi());
|
||||
syncTarget.setLogger(logger);
|
||||
synchronizers_[id] = await syncTarget.synchronizer();
|
||||
@@ -361,6 +376,7 @@ async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
|
||||
resourceFetchers_[id] = new ResourceFetcher(() => { return synchronizers_[id].api(); });
|
||||
kvStores_[id] = new KvStore();
|
||||
|
||||
await fileApi().initialize();
|
||||
await fileApi().clearRoot();
|
||||
}
|
||||
|
||||
@@ -440,7 +456,7 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
||||
return masterKey;
|
||||
}
|
||||
|
||||
async function initFileApi() {
|
||||
async function initFileApi(suiteName: string) {
|
||||
if (fileApis_[syncTargetId_]) return;
|
||||
|
||||
let fileApi = null;
|
||||
@@ -482,7 +498,6 @@ async function initFileApi() {
|
||||
|
||||
if (!process.argv.includes('--runInBand')) {
|
||||
throw new Error('OneDrive tests must be run sequentially, with the --runInBand arg. eg `npm test -- --runInBand`');
|
||||
|
||||
}
|
||||
|
||||
const { parameters, setEnvOverride } = require('@joplin/lib/parameters.js');
|
||||
@@ -506,6 +521,16 @@ async function initFileApi() {
|
||||
if (!amazonS3Creds || !amazonS3Creds.accessKeyId) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "accessKeyId": "", "secretAccessKey": "", "bucket": "mybucket"}`);
|
||||
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
|
||||
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
|
||||
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
|
||||
// Note that to test the API in parallel mode, you need to use Postgres
|
||||
// as database, as the SQLite database is not reliable when being
|
||||
// read/write from multiple processes at the same time.
|
||||
const api = new JoplinServerApi({
|
||||
baseUrl: () => 'http://localhost:22300',
|
||||
username: () => 'admin@localhost',
|
||||
password: () => 'admin',
|
||||
});
|
||||
fileApi = new FileApi(`root:/Apps/Joplin-${suiteName}`, new FileApiDriverJoplinServer(api));
|
||||
}
|
||||
|
||||
fileApi.setLogger(logger);
|
||||
@@ -743,18 +768,18 @@ class TestApp extends BaseApplication {
|
||||
private middlewareCalls_: any[];
|
||||
private logger_: LoggerWrapper;
|
||||
|
||||
constructor(hasGui = true) {
|
||||
public constructor(hasGui = true) {
|
||||
super();
|
||||
this.hasGui_ = hasGui;
|
||||
this.middlewareCalls_ = [];
|
||||
this.logger_ = super.logger();
|
||||
}
|
||||
|
||||
hasGui() {
|
||||
public hasGui() {
|
||||
return this.hasGui_;
|
||||
}
|
||||
|
||||
async start(argv: any[]) {
|
||||
public async start(argv: any[]) {
|
||||
this.logger_.info('Test app starting...');
|
||||
|
||||
if (!argv.includes('--profile')) {
|
||||
@@ -775,7 +800,7 @@ class TestApp extends BaseApplication {
|
||||
this.logger_.info('Test app started...');
|
||||
}
|
||||
|
||||
async generalMiddleware(store: any, next: any, action: any) {
|
||||
public async generalMiddleware(store: any, next: any, action: any) {
|
||||
this.middlewareCalls_.push(true);
|
||||
try {
|
||||
await super.generalMiddleware(store, next, action);
|
||||
@@ -784,7 +809,7 @@ class TestApp extends BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
async wait() {
|
||||
public async wait() {
|
||||
return new Promise((resolve) => {
|
||||
const iid = shim.setInterval(() => {
|
||||
if (!this.middlewareCalls_.length) {
|
||||
@@ -795,11 +820,11 @@ class TestApp extends BaseApplication {
|
||||
});
|
||||
}
|
||||
|
||||
async profileDir() {
|
||||
public async profileDir() {
|
||||
return Setting.value('profileDir');
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
public async destroy() {
|
||||
this.logger_.info('Test app stopping...');
|
||||
await this.wait();
|
||||
await ItemChange.waitForAllSaved();
|
||||
@@ -809,4 +834,4 @@ class TestApp extends BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { exportDir, newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
|
||||
module.exports = { afterAllCleanUp, exportDir, newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": "script-src 'self'; object-src 'self'",
|
||||
|
||||
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "1.5.11",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "1.5.11",
|
||||
"version": "1.6.0",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -139,7 +139,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097614
|
||||
versionName "1.5.1"
|
||||
versionName "1.6.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 10.5.1;
|
||||
MARKETING_VERSION = 10.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -370,7 +370,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 10.5.1;
|
||||
MARKETING_VERSION = 10.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "<%= pluginId %>",
|
||||
"app_min_version": "1.5",
|
||||
"app_min_version": "1.6",
|
||||
"version": "1.0.0",
|
||||
"name": "<%= pluginName %>",
|
||||
"description": "<%= pluginDescription %>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "1.5.3",
|
||||
"version": "1.6.0",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://joplinapp.org",
|
||||
"author": {
|
||||
@@ -26,4 +26,4 @@
|
||||
"repository": "https://github.com/laurent22/generator-joplin",
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import reducer from './reducer';
|
||||
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
||||
import { _, setLocale } from './locale';
|
||||
import KvStore from './services/KvStore';
|
||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||
|
||||
const { createStore, applyMiddleware } = require('redux');
|
||||
const { defaultState, stateUtils } = require('./reducer');
|
||||
@@ -681,6 +682,7 @@ export default class BaseApplication {
|
||||
SyncTargetRegistry.addClass(SyncTargetWebDAV);
|
||||
SyncTargetRegistry.addClass(SyncTargetDropbox);
|
||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
|
||||
try {
|
||||
await shim.fsDriver().remove(tempDir);
|
||||
|
||||
174
packages/lib/JoplinServerApi2.ts
Normal file
174
packages/lib/JoplinServerApi2.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import shim from './shim';
|
||||
const { rtrimSlashes } = require('./path-utils.js');
|
||||
const JoplinError = require('./JoplinError');
|
||||
const { stringify } = require('query-string');
|
||||
|
||||
interface Options {
|
||||
baseUrl(): string;
|
||||
username(): string;
|
||||
password(): string;
|
||||
}
|
||||
|
||||
enum ExecOptionsResponseFormat {
|
||||
Json = 'json',
|
||||
Text = 'text',
|
||||
}
|
||||
|
||||
enum ExecOptionsTarget {
|
||||
String = 'string',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
responseFormat?: ExecOptionsResponseFormat;
|
||||
target?: ExecOptionsTarget;
|
||||
path?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export default class JoplinServerApi {
|
||||
|
||||
private options_: Options;
|
||||
private session_: any;
|
||||
|
||||
public constructor(options: Options) {
|
||||
this.options_ = options;
|
||||
}
|
||||
|
||||
private baseUrl() {
|
||||
return rtrimSlashes(this.options_.baseUrl());
|
||||
}
|
||||
|
||||
private async session() {
|
||||
// TODO: handle invalid session
|
||||
if (this.session_) return this.session_;
|
||||
|
||||
this.session_ = await this.exec('POST', 'api/sessions', null, {
|
||||
email: this.options_.username(),
|
||||
password: this.options_.password(),
|
||||
});
|
||||
|
||||
return this.session_;
|
||||
}
|
||||
|
||||
private async sessionId() {
|
||||
const session = await this.session();
|
||||
return session ? session.id : '';
|
||||
}
|
||||
|
||||
// private requestToCurl_(url: string, options: any) {
|
||||
// const output = [];
|
||||
// output.push('curl');
|
||||
// output.push('-v');
|
||||
// if (options.method) output.push(`-X ${options.method}`);
|
||||
// if (options.headers) {
|
||||
// for (const n in options.headers) {
|
||||
// if (!options.headers.hasOwnProperty(n)) continue;
|
||||
// output.push(`${'-H ' + '"'}${n}: ${options.headers[n]}"`);
|
||||
// }
|
||||
// }
|
||||
// if (options.body) output.push(`${'--data ' + '\''}${JSON.stringify(options.body)}'`);
|
||||
// output.push(url);
|
||||
|
||||
// return output.join(' ');
|
||||
// }
|
||||
|
||||
public async exec(method: string, path: string = '', query: Record<string, any> = null, body: any = null, headers: any = null, options: ExecOptions = null) {
|
||||
if (headers === null) headers = {};
|
||||
if (options === null) options = {};
|
||||
if (!options.responseFormat) options.responseFormat = ExecOptionsResponseFormat.Json;
|
||||
if (!options.target) options.target = ExecOptionsTarget.String;
|
||||
|
||||
let sessionId = '';
|
||||
if (path !== 'api/sessions' && !sessionId) {
|
||||
sessionId = await this.sessionId();
|
||||
}
|
||||
|
||||
if (sessionId) headers['X-API-AUTH'] = sessionId;
|
||||
|
||||
const fetchOptions: any = {};
|
||||
fetchOptions.headers = headers;
|
||||
fetchOptions.method = method;
|
||||
if (options.path) fetchOptions.path = options.path;
|
||||
|
||||
if (body) {
|
||||
if (typeof body === 'object') {
|
||||
fetchOptions.body = JSON.stringify(body);
|
||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
||||
} else {
|
||||
fetchOptions.body = body;
|
||||
}
|
||||
|
||||
fetchOptions.headers['Content-Length'] = `${shim.stringByteLength(fetchOptions.body)}`;
|
||||
}
|
||||
|
||||
let url = `${this.baseUrl()}/${path}`;
|
||||
|
||||
if (query) {
|
||||
url += url.indexOf('?') < 0 ? '?' : '&';
|
||||
url += stringify(query);
|
||||
}
|
||||
|
||||
let response: any = null;
|
||||
|
||||
// console.info('Joplin API Call', `${method} ${url}`, headers, options);
|
||||
// console.info(this.requestToCurl_(url, fetchOptions));
|
||||
|
||||
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
|
||||
if (fetchOptions.path) {
|
||||
const fileStat = await shim.fsDriver().stat(fetchOptions.path);
|
||||
if (fileStat) fetchOptions.headers['Content-Length'] = `${fileStat.size}`;
|
||||
}
|
||||
response = await shim.uploadBlob(url, fetchOptions);
|
||||
} else if (options.target == 'string') {
|
||||
if (typeof body === 'string') fetchOptions.headers['Content-Length'] = `${shim.stringByteLength(body)}`;
|
||||
response = await shim.fetch(url, fetchOptions);
|
||||
} else {
|
||||
// file
|
||||
response = await shim.fetchBlob(url, fetchOptions);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
// console.info('Joplin API Response', responseText);
|
||||
|
||||
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
|
||||
const newError = (message: string, code: number = 0) => {
|
||||
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
|
||||
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
|
||||
const shortResponseText = (`${responseText}`).substr(0, 1024);
|
||||
return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
|
||||
};
|
||||
|
||||
let responseJson_: any = null;
|
||||
const loadResponseJson = async () => {
|
||||
if (!responseText) return null;
|
||||
if (responseJson_) return responseJson_;
|
||||
responseJson_ = JSON.parse(responseText);
|
||||
if (!responseJson_) throw newError('Cannot parse JSON response', response.status);
|
||||
return responseJson_;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
if (options.target === 'file') throw newError('fetchBlob error', response.status);
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = await loadResponseJson();
|
||||
} catch (error) {
|
||||
// Just send back the plain text in newErro()
|
||||
}
|
||||
|
||||
if (json && json.message) {
|
||||
throw newError(`${json.message}`, response.status);
|
||||
}
|
||||
|
||||
throw newError('Unknown error', response.status);
|
||||
}
|
||||
|
||||
if (options.responseFormat === 'text') return responseText;
|
||||
|
||||
const output = await loadResponseJson();
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const moment = require('moment');
|
||||
const time = require('./time').default;
|
||||
const { FsDriverDummy } = require('./fs-driver-dummy.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
export enum TargetType {
|
||||
Database = 'database',
|
||||
@@ -24,6 +25,12 @@ interface Target {
|
||||
prefix?: string;
|
||||
path?: string;
|
||||
source?: string;
|
||||
|
||||
// Default message format
|
||||
format?: string;
|
||||
|
||||
// If specified, will use this as format if it's an info message
|
||||
formatInfo?: string;
|
||||
}
|
||||
|
||||
export interface LoggerWrapper {
|
||||
@@ -173,9 +180,25 @@ class Logger {
|
||||
if (level == LogLevel.Warn) fn = 'warn';
|
||||
if (level == LogLevel.Info) fn = 'info';
|
||||
const consoleObj = target.console ? target.console : console;
|
||||
const prefixItems = [moment().format('HH:mm:ss')];
|
||||
if (targetPrefix) prefixItems.push(targetPrefix);
|
||||
const items = [`${prefixItems.join(': ')}:`].concat(...object);
|
||||
let items:any[] = [];
|
||||
|
||||
if (target.format) {
|
||||
const format = level === LogLevel.Info && target.formatInfo ? target.formatInfo : target.format;
|
||||
|
||||
const s = sprintf(format, {
|
||||
date_time: moment().format('YYYY-MM-DD HH:mm:ss'),
|
||||
level: Logger.levelIdToString(level),
|
||||
prefix: targetPrefix || '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
items = [s.trim()].concat(...object);
|
||||
} else {
|
||||
const prefixItems = [moment().format('HH:mm:ss')];
|
||||
if (targetPrefix) prefixItems.push(targetPrefix);
|
||||
items = [`${prefixItems.join(': ')}:`].concat(...object);
|
||||
}
|
||||
|
||||
consoleObj[fn](...items);
|
||||
} else if (target.type == 'file') {
|
||||
const timestamp = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
91
packages/lib/SyncTargetJoplinServer.ts
Normal file
91
packages/lib/SyncTargetJoplinServer.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
|
||||
import Setting from './models/Setting';
|
||||
import Synchronizer from './Synchronizer';
|
||||
import { _ } from './locale.js';
|
||||
import JoplinServerApi from './JoplinServerApi2';
|
||||
|
||||
const BaseSyncTarget = require('./BaseSyncTarget.js');
|
||||
const { FileApi } = require('./file-api.js');
|
||||
|
||||
interface FileApiOptions {
|
||||
path(): string;
|
||||
username(): string;
|
||||
password(): string;
|
||||
directory(): string;
|
||||
}
|
||||
|
||||
export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
||||
|
||||
static id() {
|
||||
return 9;
|
||||
}
|
||||
|
||||
static supportsConfigCheck() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static targetName() {
|
||||
return 'joplinServer';
|
||||
}
|
||||
|
||||
static label() {
|
||||
return _('Joplin Server');
|
||||
}
|
||||
|
||||
async isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static async newFileApi_(options: FileApiOptions) {
|
||||
const apiOptions = {
|
||||
baseUrl: () => options.path(),
|
||||
username: () => options.username(),
|
||||
password: () => options.password(),
|
||||
};
|
||||
|
||||
const api = new JoplinServerApi(apiOptions);
|
||||
const driver = new FileApiDriverJoplinServer(api);
|
||||
const fileApi = new FileApi(() => `root:/${options.directory()}`, driver);
|
||||
fileApi.setSyncTargetId(this.id());
|
||||
await fileApi.initialize();
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
static async checkConfig(options: FileApiOptions) {
|
||||
const fileApi = await SyncTargetJoplinServer.newFileApi_(options);
|
||||
fileApi.requestRepeatCount_ = 0;
|
||||
|
||||
const output = {
|
||||
ok: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fileApi.stat('');
|
||||
if (!result) throw new Error(`Sync directory not found: "${options.directory()}" on server "${options.path()}"`);
|
||||
output.ok = true;
|
||||
} catch (error) {
|
||||
output.errorMessage = error.message;
|
||||
if (error.code) output.errorMessage += ` (Code ${error.code})`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = await SyncTargetJoplinServer.newFileApi_({
|
||||
path: () => Setting.value('sync.9.path'),
|
||||
username: () => Setting.value('sync.9.username'),
|
||||
password: () => Setting.value('sync.9.password'),
|
||||
directory: () => Setting.value('sync.9.directory'),
|
||||
});
|
||||
|
||||
fileApi.setLogger(this.logger());
|
||||
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
async initSynchronizer() {
|
||||
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,7 @@ export default class Synchronizer {
|
||||
let syncLock = null;
|
||||
|
||||
try {
|
||||
await this.api().initialize();
|
||||
this.api().setTempDirName(Dirnames.Temp);
|
||||
|
||||
try {
|
||||
|
||||
196
packages/lib/file-api-driver-joplinServer.ts
Normal file
196
packages/lib/file-api-driver-joplinServer.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import JoplinServerApi from './JoplinServerApi2';
|
||||
const { dirname, basename } = require('./path-utils');
|
||||
|
||||
function removeTrailingColon(path: string) {
|
||||
if (!path || !path.length) return '';
|
||||
if (path[path.length - 1] === ':') return path.substr(0, path.length - 1);
|
||||
return path;
|
||||
}
|
||||
|
||||
// All input paths should be in the format: "SPECIAL_DIR:/path/to/file"
|
||||
// The trailing colon must not be included as it's automatically added
|
||||
// when doing the API call.
|
||||
// Only supported special dir at the moment is "root"
|
||||
|
||||
export default class FileApiDriverJoplinServer {
|
||||
|
||||
private api_: JoplinServerApi;
|
||||
|
||||
public constructor(api: JoplinServerApi) {
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
public async initialize(basePath: string) {
|
||||
const pieces = removeTrailingColon(basePath).split('/');
|
||||
if (!pieces.length) return;
|
||||
|
||||
let parent = pieces.splice(0, 1)[0];
|
||||
|
||||
for (const p of pieces) {
|
||||
// Syncing with the root, which is ok, and in that
|
||||
// case there's no sub-dir to create.
|
||||
if (!p && pieces.length === 1) return;
|
||||
|
||||
const subPath = `${parent}/${p}`;
|
||||
await this.mkdir(subPath);
|
||||
parent = subPath;
|
||||
}
|
||||
}
|
||||
|
||||
public api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public requestRepeatCount() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
private metadataToStat_(md: any, path: string, isDeleted: boolean = false) {
|
||||
const output = {
|
||||
path: path,
|
||||
updated_time: md.updated_time,
|
||||
isDir: !!md.is_directory,
|
||||
isDeleted: isDeleted,
|
||||
};
|
||||
|
||||
// TODO - HANDLE DELETED
|
||||
// if (md['.tag'] === 'deleted') output.isDeleted = true;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private metadataToStats_(mds: any[]) {
|
||||
const output = [];
|
||||
for (let i = 0; i < mds.length; i++) {
|
||||
output.push(this.metadataToStat_(mds[i], mds[i].name));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private apiFilePath_(p: string) {
|
||||
if (p !== 'root') p += ':';
|
||||
return `api/files/${p}`;
|
||||
}
|
||||
|
||||
public async stat(path: string) {
|
||||
try {
|
||||
const response = await this.api().exec('GET', this.apiFilePath_(path));
|
||||
return this.metadataToStat_(response, path);
|
||||
} catch (error) {
|
||||
if (error.code === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async delta(path: string, options: any) {
|
||||
const context = options ? options.context : null;
|
||||
let cursor = context ? context.cursor : null;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const query = cursor ? { cursor } : {};
|
||||
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
|
||||
const stats = response.items.map((item: any) => {
|
||||
return this.metadataToStat_(item.item, item.item.name, item.type === 3);
|
||||
});
|
||||
|
||||
const output = {
|
||||
items: stats,
|
||||
hasMore: response.has_more,
|
||||
context: { cursor: response.cursor },
|
||||
};
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
// If there's an error related to an invalid cursor, clear the cursor and retry.
|
||||
if (cursor && error.code === 'resyncRequired') {
|
||||
cursor = null;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async list(path: string, options: any = null) {
|
||||
options = {
|
||||
context: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
const query = options.context?.cursor ? { cursor: options.context.cursor } : null;
|
||||
|
||||
const results = await this.api().exec('GET', `${this.apiFilePath_(path)}/children`, query);
|
||||
|
||||
const newContext: any = {};
|
||||
if (results.cursor) newContext.cursor = results.cursor;
|
||||
|
||||
return {
|
||||
items: this.metadataToStats_(results.items),
|
||||
hasMore: results.has_more,
|
||||
context: newContext,
|
||||
} as any;
|
||||
}
|
||||
|
||||
public async get(path: string, options: any) {
|
||||
if (!options) options = {};
|
||||
if (!options.responseFormat) options.responseFormat = 'text';
|
||||
try {
|
||||
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/content`, null, null, null, options);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.code !== 404) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parentPath_(path: string) {
|
||||
let output = dirname(path);
|
||||
|
||||
// This is the root or a special folder
|
||||
if (output.split('/').length === 1) {
|
||||
output = output.substr(0, output.length - 1);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private basename_(path: string) {
|
||||
return basename(path);
|
||||
}
|
||||
|
||||
public async mkdir(path: string) {
|
||||
const parentPath = this.parentPath_(path);
|
||||
const filename = this.basename_(path);
|
||||
|
||||
try {
|
||||
const response = await this.api().exec('POST', `${this.apiFilePath_(parentPath)}/children`, null, {
|
||||
name: filename,
|
||||
is_directory: 1,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// 409 is OK - directory already exists
|
||||
if (error.code !== 409) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async put(path: string, content: any, options: any = null) {
|
||||
return this.api().exec('PUT', `${this.apiFilePath_(path)}/content`, null, content, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
}, options);
|
||||
}
|
||||
|
||||
public async delete(path: string) {
|
||||
return this.api().exec('DELETE', this.apiFilePath_(path));
|
||||
}
|
||||
|
||||
public format() {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
public async clearRoot(path: string) {
|
||||
await this.delete(path);
|
||||
await this.mkdir(path);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const time = require('./time').default;
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
const logger = Logger.create('FileApi');
|
||||
|
||||
function requestCanBeRepeated(error) {
|
||||
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
||||
|
||||
@@ -61,8 +63,14 @@ class FileApi {
|
||||
this.remoteDateOffset_ = 0;
|
||||
this.remoteDateNextCheckTime_ = 0;
|
||||
this.remoteDateMutex_ = new Mutex();
|
||||
this.initialized_ = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized_) return;
|
||||
this.initialized_ = true;
|
||||
if (this.driver_.initialize) return this.driver_.initialize(this.fullPath_(''));
|
||||
}
|
||||
|
||||
async fetchRemoteDateOffset_() {
|
||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||
@@ -108,7 +116,7 @@ class FileApi {
|
||||
this.remoteDateNextCheckTime_ = Date.now() + 10 * 60 * 1000;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().warn('Could not retrieve remote date - defaulting to device date:', error);
|
||||
logger.warn('Could not retrieve remote date - defaulting to device date:', error);
|
||||
this.remoteDateOffset_ = 0;
|
||||
this.remoteDateNextCheckTime_ = Date.now() + 60 * 1000;
|
||||
} finally {
|
||||
@@ -137,7 +145,7 @@ class FileApi {
|
||||
}
|
||||
|
||||
baseDir() {
|
||||
return this.baseDir_;
|
||||
return typeof this.baseDir_ === 'function' ? this.baseDir_() : this.baseDir_;
|
||||
}
|
||||
|
||||
tempDirName() {
|
||||
@@ -191,7 +199,7 @@ class FileApi {
|
||||
if (!('includeDirs' in options)) options.includeDirs = true;
|
||||
if (!('syncItemsOnly' in options)) options.syncItemsOnly = false;
|
||||
|
||||
this.logger().debug(`list ${this.baseDir()}`);
|
||||
logger.debug(`list ${this.baseDir()}`);
|
||||
|
||||
const result = await tryAndRepeat(() => this.driver_.list(this.fullPath_(path), options), this.requestRepeatCount());
|
||||
|
||||
@@ -216,18 +224,18 @@ class FileApi {
|
||||
|
||||
// Deprectated
|
||||
setTimestamp(path, timestampMs) {
|
||||
this.logger().debug(`setTimestamp ${this.fullPath_(path)}`);
|
||||
logger.debug(`setTimestamp ${this.fullPath_(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.setTimestamp(this.fullPath_(path), timestampMs), this.requestRepeatCount());
|
||||
// return this.driver_.setTimestamp(this.fullPath_(path), timestampMs);
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
this.logger().debug(`mkdir ${this.fullPath_(path)}`);
|
||||
logger.debug(`mkdir ${this.fullPath_(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.mkdir(this.fullPath_(path)), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
async stat(path) {
|
||||
this.logger().debug(`stat ${this.fullPath_(path)}`);
|
||||
logger.debug(`stat ${this.fullPath_(path)}`);
|
||||
|
||||
const output = await tryAndRepeat(() => this.driver_.stat(this.fullPath_(path)), this.requestRepeatCount());
|
||||
|
||||
@@ -246,12 +254,12 @@ class FileApi {
|
||||
get(path, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.encoding) options.encoding = 'utf8';
|
||||
this.logger().debug(`get ${this.fullPath_(path)}`);
|
||||
logger.debug(`get ${this.fullPath_(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.get(this.fullPath_(path), options), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
async put(path, content, options = null) {
|
||||
this.logger().debug(`put ${this.fullPath_(path)}`, options);
|
||||
logger.debug(`put ${this.fullPath_(path)}`, options);
|
||||
|
||||
if (options && options.source === 'file') {
|
||||
if (!(await this.fsDriver().exists(options.path))) throw new JoplinError(`File not found: ${options.path}`, 'fileNotFound');
|
||||
@@ -261,13 +269,13 @@ class FileApi {
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
this.logger().debug(`delete ${this.fullPath_(path)}`);
|
||||
logger.debug(`delete ${this.fullPath_(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.delete(this.fullPath_(path)), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
// Deprectated
|
||||
move(oldPath, newPath) {
|
||||
this.logger().debug(`move ${this.fullPath_(oldPath)} => ${this.fullPath_(newPath)}`);
|
||||
logger.debug(`move ${this.fullPath_(oldPath)} => ${this.fullPath_(newPath)}`);
|
||||
return tryAndRepeat(() => this.driver_.move(this.fullPath_(oldPath), this.fullPath_(newPath)), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
@@ -281,7 +289,7 @@ class FileApi {
|
||||
}
|
||||
|
||||
delta(path, options = null) {
|
||||
this.logger().debug(`delta ${this.fullPath_(path)}`);
|
||||
logger.debug(`delta ${this.fullPath_(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.delta(this.fullPath_(path), options), this.requestRepeatCount());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ function enexXmlToMdArray(stream, resources) {
|
||||
section.lines.push(BLOCK_OPEN);
|
||||
if (!state.lists.length) {
|
||||
displaySaxWarning(this, 'Found <li> tag without being inside a list');
|
||||
// return;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = state.lists[state.lists.length - 1];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import shim from '../shim';
|
||||
import { _, supportedLocalesToLanguages, defaultLocale } from '../locale';
|
||||
import { ltrimSlashes } from '../path-utils';
|
||||
const BaseModel = require('../BaseModel').default;
|
||||
const { Database } = require('../database.js');
|
||||
const SyncTargetRegistry = require('../SyncTargetRegistry.js');
|
||||
@@ -309,6 +310,52 @@ class Setting extends BaseModel {
|
||||
secure: true,
|
||||
},
|
||||
|
||||
'sync.9.path': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server URL'),
|
||||
description: () => emptyDirWarning,
|
||||
},
|
||||
'sync.9.directory': {
|
||||
value: 'Apps/Joplin',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
filter: value => {
|
||||
return value ? ltrimSlashes(rtrimSlashes(value)) : '';
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server Directory'),
|
||||
},
|
||||
'sync.9.username': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server username'),
|
||||
},
|
||||
'sync.9.password': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server password'),
|
||||
secure: true,
|
||||
},
|
||||
|
||||
'sync.5.syncTargets': { value: {}, type: SettingItemType.Object, public: false },
|
||||
|
||||
'sync.resourceDownloadMode': {
|
||||
@@ -333,6 +380,7 @@ class Setting extends BaseModel {
|
||||
'sync.3.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.4.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.7.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.9.auth': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.1.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.2.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.3.context': { value: '', type: SettingItemType.String, public: false },
|
||||
@@ -341,6 +389,7 @@ class Setting extends BaseModel {
|
||||
'sync.6.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.7.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.8.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ const shim = {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
uploadBlob: () => {
|
||||
uploadBlob: (_url: string, _options: any) => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class Time {
|
||||
}
|
||||
|
||||
msleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve: Function) => {
|
||||
shim.setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const createUuidV4 = require('uuid/v4');
|
||||
const { customAlphabet } = require('nanoid/non-secure');
|
||||
|
||||
// https://zelark.github.io/nano-id-cc/
|
||||
// https://security.stackexchange.com/a/41749/1873
|
||||
// > On the other hand, 128 bits (between 21 and 22 characters
|
||||
// > alphanumeric) is beyond the reach of brute-force attacks pretty much
|
||||
// > indefinitely
|
||||
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 22);
|
||||
|
||||
export default {
|
||||
|
||||
9
packages/server/.gitignore
vendored
Normal file
9
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist
|
||||
db.sqlite
|
||||
db-*.sqlite
|
||||
*.sqlite-journal
|
||||
*.pid
|
||||
logs/
|
||||
tests/temp/
|
||||
temp/
|
||||
66
packages/server/README.md
Normal file
66
packages/server/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Installing
|
||||
|
||||
## Configuration
|
||||
|
||||
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.
|
||||
|
||||
## Install application
|
||||
|
||||
```shell
|
||||
git clone https://github.com/laurent22/joplin
|
||||
cd joplin
|
||||
npm install
|
||||
docker-compose --file docker-compose.server.yml up --detach
|
||||
```
|
||||
|
||||
This will start the server, which will listen on port **22300** on **localhost**.
|
||||
|
||||
Due to the restart policy defined in the docker-compose file, the server will be restarted automatically whenever the host reboots.
|
||||
|
||||
## 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:
|
||||
|
||||
- [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
|
||||
|
||||
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.
|
||||
|
||||
## Setup a user for sync
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
docker-compose --file docker-compose.server.yml logs
|
||||
```
|
||||
|
||||
# Set up for development
|
||||
|
||||
## Setting 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Starting the server
|
||||
|
||||
From `packages/server`, run `npm run start-dev`
|
||||
BIN
packages/server/assets/tests/photo.jpg
Normal file
BIN
packages/server/assets/tests/photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/server/assets/tests/poster.png
Normal file
BIN
packages/server/assets/tests/poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
14
packages/server/jest.config.js
Normal file
14
packages/server/jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/assets/',
|
||||
],
|
||||
|
||||
testEnvironment: 'node',
|
||||
|
||||
slowTestThreshold: 20,
|
||||
};
|
||||
4
packages/server/nodemon.json
Normal file
4
packages/server/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"watch": ["dist/"]
|
||||
}
|
||||
8087
packages/server/package-lock.json
generated
Normal file
8087
packages/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
packages/server/package.json
Normal file
46
packages/server/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json dist/app.js --env dev",
|
||||
"start": "node dist/app.js",
|
||||
"generate-types": "rm -f db-buildTypes.sqlite && npm run start -- --migrate-db --env buildTypes && node dist/tools/generate-types.js && rm -f db-buildTypes.sqlite",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"test": "jest",
|
||||
"test-ci": "npm run test",
|
||||
"watch": "tsc --watch --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@joplin/lib": "^1.0.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bulma": "^0.9.1",
|
||||
"bulma-prefers-dark": "^0.1.0-beta.0",
|
||||
"dayjs": "^1.9.8",
|
||||
"formidable": "^1.2.2",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-entities": "^1.3.1",
|
||||
"knex": "^0.19.4",
|
||||
"koa": "^2.8.1",
|
||||
"mustache": "^3.1.0",
|
||||
"nanoid": "^2.1.1",
|
||||
"nodemon": "^2.0.6",
|
||||
"pg": "^8.5.1",
|
||||
"query-string": "^6.8.3",
|
||||
"sqlite3": "^4.1.0",
|
||||
"yargs": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "^1.0.9",
|
||||
"@rmp135/sql-ts": "^1.7.0",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/koa": "^2.0.49",
|
||||
"@types/mustache": "^0.8.32",
|
||||
"@types/yargs": "^13.0.2",
|
||||
"jest": "^26.6.3",
|
||||
"source-map-support": "^0.5.13",
|
||||
"typescript": "^4.1.2"
|
||||
}
|
||||
}
|
||||
7
packages/server/public/css/bootstrap-grid.min.css
vendored
Normal file
7
packages/server/public/css/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
packages/server/public/css/bootstrap-reboot.min.css
vendored
Normal file
8
packages/server/public/css/bootstrap-reboot.min.css
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v4.3.1 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2019 The Bootstrap Authors
|
||||
* Copyright 2011-2019 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
|
||||
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
|
||||
7
packages/server/public/css/bootstrap.min.css
vendored
Normal file
7
packages/server/public/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
0
packages/server/public/css/index/files.css
Normal file
0
packages/server/public/css/index/files.css
Normal file
3
packages/server/public/css/index/login.css
Normal file
3
packages/server/public/css/index/login.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.page-login .login-box .container {
|
||||
max-width: 400px;
|
||||
}
|
||||
35
packages/server/public/css/main.css
Normal file
35
packages/server/public/css/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
input.form-control {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.navbar .logo-container {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar .logo {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
table.table .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.table .stretch {
|
||||
width: 100%;
|
||||
}
|
||||
10
packages/server/public/css/oauth2/authorize.css
Normal file
10
packages/server/public/css/oauth2/authorize.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.form-signin {
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.authCode {
|
||||
text-align: center;
|
||||
}
|
||||
BIN
packages/server/public/images/Logo.png
Normal file
BIN
packages/server/public/images/Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
7
packages/server/public/js/bootstrap.min.js
vendored
Normal file
7
packages/server/public/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
packages/server/public/js/main.js
Normal file
8
packages/server/public/js/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
function onDocumentReady(fn) {
|
||||
if (document.readyState != 'loading') {
|
||||
fn();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
}
|
||||
170
packages/server/src/app.ts
Normal file
170
packages/server/src/app.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// Allows displaying error stack traces with TypeScript file paths
|
||||
import * as Koa from 'koa';
|
||||
import routes from './routes/routes';
|
||||
import { ErrorNotFound } from './utils/errors';
|
||||
import * as fs from 'fs-extra';
|
||||
import { argv } from 'yargs';
|
||||
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from './utils/routeUtils';
|
||||
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 { createDb, dropDb } from './tools/dbTools';
|
||||
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection } from './db';
|
||||
import modelFactory from './models/factory';
|
||||
import controllerFactory from './controllers/factory';
|
||||
import { AppContext, Config } from './utils/types';
|
||||
import FsDriverNode from '@joplin/lib/fs-driver-node';
|
||||
import mustacheService, { isView, View } from './services/MustacheService';
|
||||
|
||||
interface Configs {
|
||||
[name: string]: Config;
|
||||
}
|
||||
|
||||
const configs: Configs = {
|
||||
dev: configDev,
|
||||
prod: configProd,
|
||||
buildTypes: configBuildTypes,
|
||||
};
|
||||
|
||||
require('source-map-support').install();
|
||||
|
||||
const env: string = argv.env as string || 'prod';
|
||||
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
shimInit();
|
||||
|
||||
let appLogger_: LoggerWrapper = null;
|
||||
|
||||
function appLogger(): LoggerWrapper {
|
||||
if (!appLogger_) {
|
||||
appLogger_ = Logger.create('App');
|
||||
}
|
||||
return appLogger_;
|
||||
}
|
||||
|
||||
const app = new Koa();
|
||||
|
||||
app.use(async (ctx: Koa.Context) => {
|
||||
appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
||||
|
||||
const match: MatchedRoute = null;
|
||||
|
||||
try {
|
||||
const match = findMatchingRoute(ctx.path, routes);
|
||||
|
||||
if (match) {
|
||||
const responseObject = await match.route.exec(match.subPath, ctx);
|
||||
|
||||
if (responseObject instanceof Response) {
|
||||
ctx.response = responseObject.response;
|
||||
} else if (isView(responseObject)) {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = await mustacheService.renderView(responseObject);
|
||||
} else {
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = responseObject;
|
||||
}
|
||||
} else {
|
||||
throw new ErrorNotFound();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.httpCode >= 400 && error.httpCode < 500) {
|
||||
appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
|
||||
} else {
|
||||
appLogger().error(error);
|
||||
}
|
||||
|
||||
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
||||
|
||||
const responseFormat = routeResponseFormat(match, ctx.path);
|
||||
|
||||
if (responseFormat === RouteResponseFormat.Html) {
|
||||
ctx.response.set('Content-Type', 'text/html');
|
||||
const view: View = {
|
||||
name: 'error',
|
||||
path: 'index/error',
|
||||
content: {
|
||||
error,
|
||||
stack: env === 'dev' ? error.stack : '',
|
||||
},
|
||||
};
|
||||
ctx.response.body = await mustacheService.renderView(view);
|
||||
} else { // JSON
|
||||
ctx.response.set('Content-Type', 'application/json');
|
||||
const r: any = { error: error.message };
|
||||
if (env === 'dev' && error.stack) r.stack = error.stack;
|
||||
if (error.code) r.code = error.code;
|
||||
ctx.response.body = r;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const configObject: Config = configs[env];
|
||||
if (!configObject) throw new Error(`Invalid env: ${env}`);
|
||||
|
||||
initConfig(configObject);
|
||||
|
||||
await fs.mkdirp(config().logDir);
|
||||
Logger.fsDriver_ = new FsDriverNode();
|
||||
const globalLogger = new Logger();
|
||||
// globalLogger.addTarget(TargetType.File, { path: `${config().logDir}/app.txt` });
|
||||
globalLogger.addTarget(TargetType.Console, {
|
||||
format: '%(date_time)s: [%(level)s] %(prefix)s: %(message)s',
|
||||
formatInfo: '%(date_time)s: %(prefix)s: %(message)s',
|
||||
});
|
||||
Logger.initializeGlobalLogger(globalLogger);
|
||||
|
||||
const pidFile = argv.pidfile as string;
|
||||
|
||||
if (pidFile) {
|
||||
appLogger().info(`Writing PID to ${pidFile}...`);
|
||||
fs.removeSync(pidFile as string);
|
||||
fs.writeFileSync(pidFile, `${process.pid}`);
|
||||
}
|
||||
|
||||
if (argv.migrateDb) {
|
||||
const db = await connectDb(config().database);
|
||||
await migrateDb(db);
|
||||
await disconnectDb(db);
|
||||
} else if (argv.dropDb) {
|
||||
await dropDb(config().database, { ignoreIfNotExists: true });
|
||||
} else if (argv.dropTables) {
|
||||
const db = await connectDb(config().database);
|
||||
await dropTables(db);
|
||||
await disconnectDb(db);
|
||||
} else if (argv.createDb) {
|
||||
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);
|
||||
|
||||
const appContext = app.context as AppContext;
|
||||
|
||||
appLogger().info('Trying to connect to database...');
|
||||
const connectionCheck = await waitForConnection(config().database);
|
||||
|
||||
const connectionCheckLogInfo = { ...connectionCheck };
|
||||
delete connectionCheckLogInfo.connection;
|
||||
|
||||
appLogger().info('Connection check:', connectionCheckLogInfo);
|
||||
appContext.db = connectionCheck.connection;//
|
||||
appContext.models = modelFactory(appContext.db);
|
||||
appContext.controllers = controllerFactory(appContext.models);
|
||||
|
||||
appLogger().info('Migrating database...');
|
||||
await migrateDb(appContext.db);
|
||||
|
||||
appLogger().info(`Call this for testing: \`curl ${baseUrl()}/api/ping\``);
|
||||
|
||||
app.listen(config().port);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: any) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
21
packages/server/src/config-base.ts
Normal file
21
packages/server/src/config-base.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
13
packages/server/src/config-buildTypes.ts
Normal file
13
packages/server/src/config-buildTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Config } from './utils/types';
|
||||
import configBase from './config-base';
|
||||
|
||||
const config: Config = {
|
||||
...configBase,
|
||||
database: {
|
||||
name: 'buildTypes',
|
||||
client: 'sqlite3',
|
||||
asyncStackTraces: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
22
packages/server/src/config-dev.ts
Normal file
22
packages/server/src/config-dev.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
20
packages/server/src/config-prod.ts
Normal file
20
packages/server/src/config-prod.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
13
packages/server/src/config-tests.ts
Normal file
13
packages/server/src/config-tests.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Config } from './utils/types';
|
||||
import configBase from './config-base';
|
||||
|
||||
const config: Config = {
|
||||
...configBase,
|
||||
database: {
|
||||
name: 'DYNAMIC',
|
||||
client: 'sqlite3',
|
||||
asyncStackTraces: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
28
packages/server/src/config.ts
Normal file
28
packages/server/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
import { Config } from './utils/types';
|
||||
|
||||
let baseConfig_: Config = null;
|
||||
let baseUrl_: string = null;
|
||||
|
||||
export function initConfig(baseConfig: Config) {
|
||||
baseConfig_ = baseConfig;
|
||||
}
|
||||
|
||||
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_;
|
||||
}
|
||||
|
||||
export default config;
|
||||
25
packages/server/src/controllers/BaseController.ts
Normal file
25
packages/server/src/controllers/BaseController.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
535
packages/server/src/controllers/api/FileController.test.ts
Normal file
535
packages/server/src/controllers/api/FileController.test.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { testAssetDir, createUserAndSession, createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, models, controllers } from '../../utils/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);
|
||||
});
|
||||
|
||||
});
|
||||
101
packages/server/src/controllers/api/FileController.ts
Normal file
101
packages/server/src/controllers/api/FileController.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
54
packages/server/src/controllers/api/OAuthController.ts
Normal file
54
packages/server/src/controllers/api/OAuthController.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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'],
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// }
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createUser, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb, controllers } from '../../utils/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);
|
||||
});
|
||||
|
||||
});
|
||||
19
packages/server/src/controllers/api/SessionController.ts
Normal file
19
packages/server/src/controllers/api/SessionController.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Session, User } from '../../db';
|
||||
import { checkPassword } from '../../utils/auth';
|
||||
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: User = await userModel.loadByEmail(email);
|
||||
if (!user) throw new ErrorForbidden('Invalid username or password');
|
||||
if (!checkPassword(password, user.password)) throw new ErrorForbidden('Invalid username or password');
|
||||
const session: Session = { id: uuidgen(), user_id: user.id };
|
||||
const sessionModel = this.models.session();
|
||||
return sessionModel.save(session, { isNew: true });
|
||||
}
|
||||
|
||||
}
|
||||
192
packages/server/src/controllers/api/UserController.test.ts
Normal file
192
packages/server/src/controllers/api/UserController.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { models, controllers, createUserAndSession, checkThrowAsync, beforeAllDb, afterAllDb, beforeEachDb } from '../../utils/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);
|
||||
});
|
||||
|
||||
});
|
||||
33
packages/server/src/controllers/api/UserController.ts
Normal file
33
packages/server/src/controllers/api/UserController.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
60
packages/server/src/controllers/factory.ts
Normal file
60
packages/server/src/controllers/factory.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
|
||||
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_);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default function(models: Models) {
|
||||
return new Controllers(models);
|
||||
}
|
||||
85
packages/server/src/controllers/index/FileController.ts
Normal file
85
packages/server/src/controllers/index/FileController.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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 user = await this.initSession(sessionId);
|
||||
const fileModel = this.models.file({ userId: user.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 = `${baseUrl()}/files/${await fileModel.itemFullPath(parent)}`;
|
||||
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
|
||||
|
||||
async function fileToViewItem(file: File): Promise<any> {
|
||||
const filePath = await fileModel.itemFullPath(file);
|
||||
|
||||
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[] = [];
|
||||
|
||||
if (parent.id !== root.id) {
|
||||
const p = await fileModel.load(parent.parent_id);
|
||||
files.push({
|
||||
...await fileToViewItem(p),
|
||||
icon: 'fas fa-arrow-left',
|
||||
name: '..',
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of paginatedFiles.items) {
|
||||
files.push(await fileToViewItem(file));
|
||||
}
|
||||
|
||||
const view: View = defaultView('files', owner);
|
||||
view.content.paginatedFiles = { ...paginatedFiles, items: files };
|
||||
view.content.paginationLinks = paginationLinks;
|
||||
view.cssFiles = ['index/files'];
|
||||
view.partials.push('pagination');
|
||||
return view;
|
||||
}
|
||||
|
||||
}
|
||||
12
packages/server/src/controllers/index/HomeController.ts
Normal file
12
packages/server/src/controllers/index/HomeController.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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> {
|
||||
const owner = await this.initSession(sessionId);
|
||||
return defaultView('home', owner);
|
||||
}
|
||||
|
||||
}
|
||||
14
packages/server/src/controllers/index/LoginController.ts
Normal file
14
packages/server/src/controllers/index/LoginController.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
31
packages/server/src/controllers/index/ProfileController.ts
Normal file
31
packages/server/src/controllers/index/ProfileController.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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', owner);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
42
packages/server/src/controllers/index/UserController.ts
Normal file
42
packages/server/src/controllers/index/UserController.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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);
|
||||
const userModel = this.models.user({ userId: owner.id });
|
||||
const users = await userModel.all();
|
||||
|
||||
const view: View = defaultView('users', owner);
|
||||
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', owner);
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
324
packages/server/src/db.ts
Normal file
324
packages/server/src/db.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import * as Knex from 'knex';
|
||||
import { DatabaseConfig } from './utils/types';
|
||||
import * as pathUtils from 'path';
|
||||
import time from '@joplin/lib/time';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
|
||||
// Make sure bigInteger values are numbers and not strings
|
||||
//
|
||||
// https://github.com/brianc/node-pg-types
|
||||
//
|
||||
// In our case, all bigInteger are timestamps, which JavaScript can handle
|
||||
// fine as numbers.
|
||||
require('pg').types.setTypeParser(20, function(val: any) {
|
||||
return parseInt(val, 10);
|
||||
});
|
||||
|
||||
const logger = Logger.create('db');
|
||||
|
||||
const migrationDir = `${__dirname}/migrations`;
|
||||
const sqliteDbDir = pathUtils.dirname(__dirname);
|
||||
|
||||
export type DbConnection = Knex;
|
||||
|
||||
export interface DbConfigConnection {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
database?: string;
|
||||
filename?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface KnexDatabaseConfig {
|
||||
client: string;
|
||||
connection: DbConfigConnection;
|
||||
useNullAsDefault?: boolean;
|
||||
asyncStackTraces?: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionCheckResult {
|
||||
isCreated: boolean;
|
||||
error: any;
|
||||
latestMigration: any;
|
||||
connection: DbConnection;
|
||||
}
|
||||
|
||||
export function sqliteFilePath(dbConfig: DatabaseConfig): string {
|
||||
return `${sqliteDbDir}/db-${dbConfig.name}.sqlite`;
|
||||
}
|
||||
|
||||
export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig {
|
||||
const connection: DbConfigConnection = {};
|
||||
|
||||
if (dbConfig.client === 'sqlite3') {
|
||||
connection.filename = sqliteFilePath(dbConfig);
|
||||
} else {
|
||||
connection.database = dbConfig.name;
|
||||
connection.host = dbConfig.host;
|
||||
connection.port = dbConfig.port;
|
||||
connection.user = dbConfig.user;
|
||||
connection.password = dbConfig.password;
|
||||
}
|
||||
|
||||
return {
|
||||
client: dbConfig.client,
|
||||
useNullAsDefault: dbConfig.client === 'sqlite3',
|
||||
asyncStackTraces: dbConfig.asyncStackTraces,
|
||||
connection,
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForConnection(dbConfig: DatabaseConfig): Promise<ConnectionCheckResult> {
|
||||
const timeout = 30000;
|
||||
const startTime = Date.now();
|
||||
let lastError = { message: '' };
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const connection = await connectDb(dbConfig);
|
||||
const check = await connectionCheck(connection);
|
||||
if (check.error) throw check.error;
|
||||
return check;
|
||||
} catch (error) {
|
||||
logger.info('Could not connect. Will try again.', error.message);
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime > timeout) {
|
||||
logger.error('Timeout trying to connect to database:', lastError);
|
||||
throw new Error(`Timeout trying to connect to database. Last error was: ${lastError.message}`);
|
||||
}
|
||||
|
||||
await time.msleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
|
||||
return require('knex')(makeKnexConfig(dbConfig));
|
||||
}
|
||||
|
||||
export async function disconnectDb(db: DbConnection) {
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
export async function migrateDb(db: DbConnection) {
|
||||
await db.migrate.latest({
|
||||
directory: migrationDir,
|
||||
// Disable transactions because the models might open one too
|
||||
disableTransactions: true,
|
||||
});
|
||||
}
|
||||
|
||||
function allTableNames(): string[] {
|
||||
const tableNames = Object.keys(databaseSchema);
|
||||
tableNames.push('knex_migrations');
|
||||
tableNames.push('knex_migrations_lock');
|
||||
return tableNames;
|
||||
}
|
||||
|
||||
export async function dropTables(db: DbConnection): Promise<void> {
|
||||
for (const tableName of allTableNames()) {
|
||||
try {
|
||||
await db.schema.dropTable(tableName);
|
||||
} catch (error) {
|
||||
if (isNoSuchTableError(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNoSuchTableError(error: any): boolean {
|
||||
if (error) {
|
||||
// Postgres error: 42P01: undefined_table
|
||||
if (error.code === '42P01') return true;
|
||||
|
||||
// Sqlite3 error
|
||||
if (error.message && error.message.includes('no such table: knex_migrations')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function latestMigration(db: DbConnection): Promise<any> {
|
||||
try {
|
||||
const result = await db('knex_migrations').select('name').orderBy('id', 'asc').first();
|
||||
return result;
|
||||
} catch (error) {
|
||||
// If the database has never been initialized, we return null, so
|
||||
// for this we need to check the error code, which will be
|
||||
// different depending on the DBMS.
|
||||
|
||||
if (isNoSuchTableError(error)) return null;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectionCheck(db: DbConnection): Promise<ConnectionCheckResult> {
|
||||
try {
|
||||
const result = await latestMigration(db);
|
||||
return {
|
||||
latestMigration: result,
|
||||
isCreated: !!result,
|
||||
error: null,
|
||||
connection: db,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
latestMigration: null,
|
||||
isCreated: false,
|
||||
error: error,
|
||||
connection: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type Uuid = string;
|
||||
|
||||
export enum ItemAddressingType {
|
||||
Id = 1,
|
||||
Path,
|
||||
}
|
||||
|
||||
export enum ItemType {
|
||||
File = 1,
|
||||
User,
|
||||
}
|
||||
|
||||
export enum ChangeType {
|
||||
Create = 1,
|
||||
Update = 2,
|
||||
Delete = 3,
|
||||
}
|
||||
|
||||
export interface WithDates {
|
||||
updated_time?: number;
|
||||
created_time?: number;
|
||||
}
|
||||
|
||||
export interface WithUuid {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface DatabaseTableColumn {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface DatabaseTable {
|
||||
[key: string]: DatabaseTableColumn;
|
||||
}
|
||||
|
||||
interface DatabaseTables {
|
||||
[key: string]: DatabaseTable;
|
||||
}
|
||||
|
||||
// AUTO-GENERATED-TYPES
|
||||
// Auto-generated using `npm run generate-types`
|
||||
export interface User extends WithDates, WithUuid {
|
||||
email?: string;
|
||||
password?: string;
|
||||
full_name?: string;
|
||||
is_admin?: number;
|
||||
}
|
||||
|
||||
export interface Session extends WithDates, WithUuid {
|
||||
user_id?: Uuid;
|
||||
auth_code?: string;
|
||||
}
|
||||
|
||||
export interface Permission extends WithDates, WithUuid {
|
||||
user_id?: Uuid;
|
||||
item_type?: ItemType;
|
||||
item_id?: Uuid;
|
||||
can_read?: number;
|
||||
can_write?: number;
|
||||
}
|
||||
|
||||
export interface File extends WithDates, WithUuid {
|
||||
owner_id?: Uuid;
|
||||
name?: string;
|
||||
content?: Buffer;
|
||||
mime_type?: string;
|
||||
size?: number;
|
||||
is_directory?: number;
|
||||
is_root?: number;
|
||||
parent_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
counter?: number;
|
||||
owner_id?: Uuid;
|
||||
item_type?: ItemType;
|
||||
parent_id?: Uuid;
|
||||
item_id?: Uuid;
|
||||
item_name?: string;
|
||||
type?: ChangeType;
|
||||
}
|
||||
|
||||
export interface ApiClient extends WithDates, WithUuid {
|
||||
name?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export const databaseSchema: DatabaseTables = {
|
||||
users: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
full_name: { type: 'string' },
|
||||
is_admin: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
sessions: {
|
||||
id: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
auth_code: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
permissions: {
|
||||
id: { type: 'string' },
|
||||
user_id: { type: 'string' },
|
||||
item_type: { type: 'number' },
|
||||
item_id: { type: 'string' },
|
||||
can_read: { type: 'number' },
|
||||
can_write: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
files: {
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
content: { type: 'any' },
|
||||
mime_type: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
is_directory: { type: 'number' },
|
||||
is_root: { type: 'number' },
|
||||
parent_id: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
changes: {
|
||||
counter: { type: 'number' },
|
||||
id: { type: 'string' },
|
||||
owner_id: { type: 'string' },
|
||||
item_type: { type: 'number' },
|
||||
parent_id: { type: 'string' },
|
||||
item_id: { type: 'string' },
|
||||
item_name: { type: 'string' },
|
||||
type: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
api_clients: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
secret: { type: 'string' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
},
|
||||
};
|
||||
// AUTO-GENERATED-TYPES
|
||||
135
packages/server/src/migrations/20190913171451_create.ts
Normal file
135
packages/server/src/migrations/20190913171451_create.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as Knex from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
import { hashPassword } from '../utils/auth';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.createTable('users', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.text('email', 'mediumtext').unique().notNullable();
|
||||
table.text('password', 'mediumtext').notNullable();
|
||||
table.text('full_name', 'mediumtext').defaultTo('').notNullable();
|
||||
table.integer('is_admin').defaultTo(0).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
|
||||
table.index(['email']);
|
||||
});
|
||||
|
||||
await db.schema.createTable('sessions', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.string('auth_code', 32).defaultTo('').notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.createTable('permissions', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('user_id', 32).notNullable();
|
||||
table.integer('item_type').notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.integer('can_read').defaultTo(0).notNullable();
|
||||
table.integer('can_write').defaultTo(0).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('permissions', function(table: Knex.CreateTableBuilder) {
|
||||
table.unique(['user_id', 'item_type', 'item_id']);
|
||||
table.index(['item_id']);
|
||||
table.index(['item_type', 'item_id']);
|
||||
});
|
||||
|
||||
await db.schema.createTable('files', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('owner_id', 32).notNullable();
|
||||
table.text('name').notNullable();
|
||||
table.binary('content').defaultTo('').notNullable();
|
||||
table.string('mime_type', 128).defaultTo('application/octet-stream').notNullable();
|
||||
table.integer('size').defaultTo(0).notNullable();
|
||||
table.integer('is_directory').defaultTo(0).notNullable();
|
||||
table.integer('is_root').defaultTo(0).notNullable();
|
||||
table.string('parent_id', 32).defaultTo('').notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
|
||||
table.unique(['parent_id', 'name']);
|
||||
table.index(['parent_id']);
|
||||
});
|
||||
|
||||
await db.schema.createTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||
// Note that in this table, the counter is the primary key, since
|
||||
// we want it to be automatically incremented. There's also a
|
||||
// column ID to publicly identify a change.
|
||||
table.increments('counter').unique().primary().notNullable();
|
||||
table.string('id', 32).unique().notNullable();
|
||||
table.string('owner_id', 32).notNullable();
|
||||
table.integer('item_type').notNullable();
|
||||
table.string('parent_id', 32).defaultTo('').notNullable();
|
||||
table.string('item_id', 32).notNullable();
|
||||
table.text('item_name').defaultTo('').notNullable();
|
||||
table.integer('type').notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
|
||||
table.index(['id']);
|
||||
table.index(['parent_id']);
|
||||
});
|
||||
|
||||
await db.schema.createTable('api_clients', function(table: Knex.CreateTableBuilder) {
|
||||
table.string('id', 32).unique().primary().notNullable();
|
||||
table.string('name', 32).notNullable();
|
||||
table.string('secret', 32).notNullable();
|
||||
table.bigInteger('updated_time').notNullable();
|
||||
table.bigInteger('created_time').notNullable();
|
||||
});
|
||||
|
||||
const adminId = uuidgen();
|
||||
const adminRootFileId = uuidgen();
|
||||
const now = Date.now();
|
||||
|
||||
await db('users').insert({
|
||||
id: adminId,
|
||||
email: 'admin@localhost',
|
||||
password: hashPassword('admin'),
|
||||
full_name: 'Admin',
|
||||
is_admin: 1,
|
||||
updated_time: now,
|
||||
created_time: now,
|
||||
});
|
||||
|
||||
await db('files').insert({
|
||||
id: adminRootFileId,
|
||||
owner_id: adminId,
|
||||
name: adminRootFileId,
|
||||
size: 0,
|
||||
is_directory: 1,
|
||||
is_root: 1,
|
||||
updated_time: now,
|
||||
created_time: now,
|
||||
});
|
||||
|
||||
await db('api_clients').insert({
|
||||
id: uuidgen(),
|
||||
name: 'Joplin',
|
||||
secret: 'sdrNUPtKNdY5Z5tF4bthqu',
|
||||
updated_time: now,
|
||||
created_time: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.dropTable('users');
|
||||
await db.schema.dropTable('sessions');
|
||||
await db.schema.dropTable('permissions');
|
||||
await db.schema.dropTable('files');
|
||||
await db.schema.dropTable('api_clients');
|
||||
await db.schema.dropTable('changes');
|
||||
}
|
||||
9
packages/server/src/models/ApiClientModel.ts
Normal file
9
packages/server/src/models/ApiClientModel.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
export default class ApiClientModel extends BaseModel {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'api_clients';
|
||||
}
|
||||
|
||||
}
|
||||
239
packages/server/src/models/BaseModel.ts
Normal file
239
packages/server/src/models/BaseModel.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { WithDates, WithUuid, File, User, Session, Permission, databaseSchema, ApiClient, DbConnection, Change, ItemType, ChangeType } from '../db';
|
||||
import TransactionHandler from '../utils/TransactionHandler';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||
import { Models } from './factory';
|
||||
|
||||
export type AnyItemType = File | User | Session | Permission | ApiClient | Change;
|
||||
export type AnyItemTypes = File[] | User[] | Session[] | Permission[] | ApiClient[] | Change[];
|
||||
|
||||
export interface ModelOptions {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface SaveOptions {
|
||||
isNew?: boolean;
|
||||
skipValidation?: boolean;
|
||||
validationRules?: any;
|
||||
trackChanges?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteOptions {
|
||||
validationRules?: any;
|
||||
}
|
||||
|
||||
export interface ValidateOptions {
|
||||
isNew?: boolean;
|
||||
rules?: any;
|
||||
}
|
||||
|
||||
export default abstract class BaseModel {
|
||||
|
||||
private options_: ModelOptions = null;
|
||||
private defaultFields_: string[] = [];
|
||||
private db_: DbConnection;
|
||||
private transactionHandler_: TransactionHandler;
|
||||
private modelFactory_: Function;
|
||||
|
||||
public constructor(db: DbConnection, modelFactory: Function, options: ModelOptions = null) {
|
||||
this.db_ = db;
|
||||
this.modelFactory_ = modelFactory;
|
||||
this.options_ = Object.assign({}, options);
|
||||
|
||||
this.transactionHandler_ = new TransactionHandler(db);
|
||||
|
||||
if ('userId' in this.options && !this.options.userId) throw new Error('If userId is set, it cannot be null');
|
||||
}
|
||||
|
||||
// When a model create an instance of another model, the active
|
||||
// connection is passed to it. That connection can be the regular db
|
||||
// connection, or the active transaction.
|
||||
protected models(db: DbConnection = null): Models {
|
||||
return this.modelFactory_(db || this.db);
|
||||
}
|
||||
|
||||
protected get options(): ModelOptions {
|
||||
return this.options_;
|
||||
}
|
||||
|
||||
protected get userId(): string {
|
||||
return this.options.userId;
|
||||
}
|
||||
|
||||
protected get db(): DbConnection {
|
||||
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
protected get defaultFields(): string[] {
|
||||
if (!this.defaultFields_.length) {
|
||||
this.defaultFields_ = Object.keys(databaseSchema[this.tableName]);
|
||||
}
|
||||
return this.defaultFields_.slice();
|
||||
}
|
||||
|
||||
protected get tableName(): string {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
protected get itemType(): ItemType {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
protected get trackChanges(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected hasDateProperties(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected get hasParentId(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async withTransaction(fn: Function): Promise<void> {
|
||||
const txIndex = await this.transactionHandler_.start();
|
||||
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
await this.transactionHandler_.rollback(txIndex);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.transactionHandler_.commit(txIndex);
|
||||
}
|
||||
|
||||
public async all(): Promise<AnyItemTypes> {
|
||||
return this.db(this.tableName).select(...this.defaultFields);
|
||||
}
|
||||
|
||||
public fromApiInput(object: AnyItemType): AnyItemType {
|
||||
return object;
|
||||
}
|
||||
|
||||
public toApiOutput(object: any): any {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
protected async validate(object: AnyItemType, options: ValidateOptions = {}): Promise<AnyItemType> {
|
||||
if (!options.isNew && !(object as WithUuid).id) throw new ErrorUnprocessableEntity('id is missing');
|
||||
return object;
|
||||
}
|
||||
|
||||
protected async isNew(object: AnyItemType, options: SaveOptions): Promise<boolean> {
|
||||
if (options.isNew === false) return false;
|
||||
if (options.isNew === true) return true;
|
||||
return !(object as WithUuid).id;
|
||||
}
|
||||
|
||||
private async handleChangeTracking(options: SaveOptions, item: AnyItemType, changeType: ChangeType): Promise<void> {
|
||||
const trackChanges = this.trackChanges && options.trackChanges !== false;
|
||||
if (!trackChanges) return;
|
||||
|
||||
let parentId = null;
|
||||
if (this.hasParentId) {
|
||||
if (!('parent_id' in item)) {
|
||||
const temp: any = await this.db(this.tableName).select(['parent_id']).where('id', '=', item.id).first();
|
||||
parentId = temp.parent_id;
|
||||
} else {
|
||||
parentId = item.parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check - shouldn't happen
|
||||
// Parent ID can be an empty string for root folders, but it shouldn't be null or undefined
|
||||
if (this.hasParentId && !parentId && parentId !== '') throw new Error(`Could not find parent ID for item: ${item.id}`);
|
||||
|
||||
const changeModel = this.models().change({ userId: this.userId });
|
||||
await changeModel.add(this.itemType, parentId, (item as WithUuid).id, (item as any).name || '', changeType);
|
||||
}
|
||||
|
||||
public async save(object: AnyItemType, options: SaveOptions = {}): Promise<AnyItemType> {
|
||||
if (!object) throw new Error('Object cannot be empty');
|
||||
|
||||
const toSave = Object.assign({}, object);
|
||||
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
if (isNew && !(toSave as WithUuid).id) {
|
||||
(toSave as WithUuid).id = uuidgen();
|
||||
}
|
||||
|
||||
if (this.hasDateProperties()) {
|
||||
const timestamp = Date.now();
|
||||
if (isNew) {
|
||||
(toSave as WithDates).created_time = timestamp;
|
||||
}
|
||||
(toSave as WithDates).updated_time = timestamp;
|
||||
}
|
||||
|
||||
if (options.skipValidation !== true) object = await this.validate(object, { isNew: isNew, rules: options.validationRules ? options.validationRules : {} });
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
if (isNew) {
|
||||
await this.db(this.tableName).insert(toSave);
|
||||
await this.handleChangeTracking(options, toSave, ChangeType.Create);
|
||||
} else {
|
||||
const objectId: string = (toSave as WithUuid).id;
|
||||
if (!objectId) throw new Error('Missing "id" property');
|
||||
delete (toSave as WithUuid).id;
|
||||
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
|
||||
toSave.id = objectId;
|
||||
|
||||
await this.handleChangeTracking(options, toSave, ChangeType.Update);
|
||||
|
||||
// Sanity check:
|
||||
if (updatedCount !== 1) throw new ErrorBadRequest(`one row should have been updated, but ${updatedCount} row(s) were updated`);
|
||||
}
|
||||
});
|
||||
|
||||
return toSave;
|
||||
}
|
||||
|
||||
public async loadByIds(ids: string[]): Promise<AnyItemType[]> {
|
||||
if (!ids.length) return [];
|
||||
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids);
|
||||
}
|
||||
|
||||
public async load(id: string): Promise<AnyItemType> {
|
||||
if (!id) throw new Error('id cannot be empty');
|
||||
|
||||
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first();
|
||||
}
|
||||
|
||||
public async delete(id: string | string[]): Promise<void> {
|
||||
if (!id) throw new Error('id cannot be empty');
|
||||
|
||||
const ids = typeof id === 'string' ? [id] : id;
|
||||
|
||||
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;
|
||||
if (trackChanges) {
|
||||
itemsWithParentIds = await this.db(this.tableName).select(['id', 'parent_id', 'name']).whereIn('id', ids);
|
||||
}
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
119
packages/server/src/models/ChangeModel.test.ts
Normal file
119
packages/server/src/models/ChangeModel.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testUtils';
|
||||
import { ChangeType, File } from '../db';
|
||||
import FileModel from './FileModel';
|
||||
import { msleep } from '../utils/time';
|
||||
import { ChangePagination } from './ChangeModel';
|
||||
|
||||
async function makeTestFile(fileModel: FileModel): Promise<File> {
|
||||
return fileModel.save({
|
||||
name: 'test',
|
||||
parent_id: await fileModel.userRootFileId(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChangeModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('ChangeModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should track changes - create', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
|
||||
const file1 = await makeTestFile(fileModel);
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
|
||||
{
|
||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
||||
expect(changes.length).toBe(1);
|
||||
expect(changes[0].item.id).toBe(file1.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
}
|
||||
});
|
||||
|
||||
test('should track changes - create, then update', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
|
||||
let i = 1;
|
||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
await msleep(1); const file2 = await makeTestFile(fileModel); // CREATE 2
|
||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
||||
await msleep(1); await fileModel.delete(file1.id); // DELETE 1
|
||||
await msleep(1); await fileModel.save({ id: file2.id, name: `test_mod${i++}` }); // UPDATE 2
|
||||
await msleep(1); const file3 = await makeTestFile(fileModel); // CREATE 3
|
||||
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
|
||||
{
|
||||
const changes = (await changeModel.byDirectoryId(dirId, { limit: 20 })).items;
|
||||
expect(changes.length).toBe(2);
|
||||
expect(changes[0].item.id).toBe(file2.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
expect(changes[1].item.id).toBe(file3.id);
|
||||
expect(changes[1].type).toBe(ChangeType.Create);
|
||||
}
|
||||
|
||||
{
|
||||
const pagination: ChangePagination = { limit: 5 };
|
||||
|
||||
// In this page, the "create" change for file1 will not appear
|
||||
// because this file has been deleted. The "delete" change will
|
||||
// however appear in the second page.
|
||||
const page1 = (await changeModel.byDirectoryId(dirId, pagination));
|
||||
let changes = page1.items;
|
||||
expect(changes.length).toBe(1);
|
||||
expect(page1.has_more).toBe(true);
|
||||
expect(changes[0].item.id).toBe(file2.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Create);
|
||||
|
||||
const page2 = (await changeModel.byDirectoryId(dirId, { ...pagination, cursor: page1.cursor }));
|
||||
changes = page2.items;
|
||||
expect(changes.length).toBe(3);
|
||||
expect(page2.has_more).toBe(false);
|
||||
expect(changes[0].item.id).toBe(file1.id);
|
||||
expect(changes[0].type).toBe(ChangeType.Delete);
|
||||
expect(changes[1].item.id).toBe(file2.id);
|
||||
expect(changes[1].type).toBe(ChangeType.Update);
|
||||
expect(changes[2].item.id).toBe(file3.id);
|
||||
expect(changes[2].type).toBe(ChangeType.Create);
|
||||
}
|
||||
});
|
||||
|
||||
test('should throw an error if cursor is invalid', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
|
||||
const dirId = await fileModel.userRootFileId();
|
||||
|
||||
let i = 1;
|
||||
await msleep(1); const file1 = await makeTestFile(fileModel); // CREATE 1
|
||||
await msleep(1); await fileModel.save({ id: file1.id, name: `test_mod${i++}` }); // UPDATE 1
|
||||
|
||||
await expectThrow(async () => changeModel.byDirectoryId(dirId, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
|
||||
});
|
||||
|
||||
test('should throw an error if trying to do get changes for a file', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const changeModel = models().change({ userId: user.id });
|
||||
const file1 = await makeTestFile(fileModel);
|
||||
|
||||
await expectThrow(async () => changeModel.byDirectoryId(file1.id));
|
||||
});
|
||||
|
||||
});
|
||||
182
packages/server/src/models/ChangeModel.ts
Normal file
182
packages/server/src/models/ChangeModel.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Change, ChangeType, File, ItemType, Uuid } from '../db';
|
||||
import { ErrorResyncRequired, ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import BaseModel from './BaseModel';
|
||||
import { PaginatedResults } from './utils/pagination';
|
||||
|
||||
export interface ChangeWithItem {
|
||||
item: File;
|
||||
type: ChangeType;
|
||||
}
|
||||
|
||||
export interface PaginatedChanges extends PaginatedResults {
|
||||
items: ChangeWithItem[];
|
||||
}
|
||||
|
||||
export interface ChangePagination {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export function defaultChangePagination(): ChangePagination {
|
||||
return {
|
||||
limit: 100,
|
||||
cursor: '',
|
||||
};
|
||||
}
|
||||
|
||||
export default class ChangeModel extends BaseModel {
|
||||
|
||||
public get tableName(): string {
|
||||
return 'changes';
|
||||
}
|
||||
|
||||
protected hasUuid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async add(itemType: ItemType, parentId: Uuid, itemId: Uuid, itemName: string, changeType: ChangeType): Promise<Change> {
|
||||
const change: Change = {
|
||||
item_type: itemType,
|
||||
parent_id: parentId || '',
|
||||
item_id: itemId,
|
||||
item_name: itemName,
|
||||
type: changeType,
|
||||
owner_id: this.userId,
|
||||
};
|
||||
|
||||
return this.save(change);
|
||||
}
|
||||
|
||||
// Note: doesn't currently support checking for changes recursively but this
|
||||
// is not needed for Joplin synchronisation.
|
||||
public async byDirectoryId(dirId: string, pagination: ChangePagination = null): Promise<PaginatedChanges> {
|
||||
pagination = {
|
||||
...defaultChangePagination(),
|
||||
...pagination,
|
||||
};
|
||||
|
||||
let changeAtCursor: Change = null;
|
||||
|
||||
if (pagination.cursor) {
|
||||
changeAtCursor = await this.load(pagination.cursor);
|
||||
if (!changeAtCursor) throw new ErrorResyncRequired();
|
||||
}
|
||||
|
||||
// Load the directory object to check that it exists and that we have
|
||||
// the right permissions (loading will check permissions)
|
||||
const fileModel = this.models().file({ userId: this.userId });
|
||||
const directory = await fileModel.load(dirId);
|
||||
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
|
||||
|
||||
// Rather than query the changes, then use JS to compress them, it might
|
||||
// be possible to do both in one query.
|
||||
// https://stackoverflow.com/questions/65348794
|
||||
const query = this.db(this.tableName)
|
||||
.select([
|
||||
'counter',
|
||||
'id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'type',
|
||||
])
|
||||
.where('parent_id', dirId)
|
||||
.orderBy('counter', 'asc')
|
||||
.limit(pagination.limit);
|
||||
|
||||
if (changeAtCursor) {
|
||||
void query.where('counter', '>', changeAtCursor.counter);
|
||||
}
|
||||
|
||||
const changes: Change[] = await query;
|
||||
const compressedChanges = this.compressChanges(changes);
|
||||
const changeWithItems = await this.loadChangeItems(compressedChanges);
|
||||
|
||||
return {
|
||||
items: changeWithItems,
|
||||
// If we have changes, we return the ID of the latest changes from which delta sync can resume.
|
||||
// If there's no change, we return the previous cursor.
|
||||
cursor: changes.length ? changes[changes.length - 1].id : pagination.cursor,
|
||||
has_more: changes.length >= pagination.limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
|
||||
const itemIds = changes.map(c => c.item_id);
|
||||
const fileModel = this.models().file({ userId: this.userId });
|
||||
const items: File[] = await fileModel.loadByIds(itemIds);
|
||||
|
||||
const output: ChangeWithItem[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
let item = items.find(f => f.id === change.item_id);
|
||||
|
||||
// If the item associated with this change has been deleted, we have
|
||||
// two cases:
|
||||
// - If it's a "delete" change, add it to the list.
|
||||
// - If it's anything else, skip it. The "delete" change will be
|
||||
// sent on one of the next pages.
|
||||
|
||||
if (!item) {
|
||||
if (change.type === ChangeType.Delete) {
|
||||
item = {
|
||||
id: change.item_id,
|
||||
name: change.item_name,
|
||||
};
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output.push({
|
||||
type: change.type,
|
||||
item: item,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private compressChanges(changes: Change[]): Change[] {
|
||||
const itemChanges: Record<Uuid, Change> = {};
|
||||
|
||||
for (const change of changes) {
|
||||
const previous = itemChanges[change.item_id];
|
||||
|
||||
if (previous) {
|
||||
// create - update => create
|
||||
// create - delete => NOOP
|
||||
// update - update => update
|
||||
// update - delete => delete
|
||||
|
||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
|
||||
delete itemChanges[change.item_id];
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
|
||||
itemChanges[change.item_id] = change;
|
||||
}
|
||||
|
||||
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
|
||||
itemChanges[change.item_id] = change;
|
||||
}
|
||||
} else {
|
||||
itemChanges[change.item_id] = change;
|
||||
}
|
||||
}
|
||||
|
||||
const output = [];
|
||||
|
||||
for (const itemId in itemChanges) {
|
||||
output.push(itemChanges[itemId]);
|
||||
}
|
||||
|
||||
output.sort((a: Change, b: Change) => a.counter < b.counter ? -1 : +1);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
56
packages/server/src/models/FileModel.test.ts
Normal file
56
packages/server/src/models/FileModel.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, createFileTree } from '../utils/testUtils';
|
||||
import { File } from '../db';
|
||||
|
||||
describe('FileModel', function() {
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('FileModel');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
test('should compute item full path', async function() {
|
||||
const { user } = await createUserAndSession(1, true);
|
||||
const fileModel = models().file({ userId: user.id });
|
||||
const rootId = await fileModel.userRootFileId();
|
||||
|
||||
const tree: any = {
|
||||
folder1: {},
|
||||
folder2: {
|
||||
file2_1: null,
|
||||
file2_2: null,
|
||||
},
|
||||
folder3: {
|
||||
file3_1: null,
|
||||
},
|
||||
file1: null,
|
||||
file2: null,
|
||||
file3: null,
|
||||
};
|
||||
|
||||
await createFileTree(fileModel, rootId, tree);
|
||||
|
||||
const testCases = Object.keys(tree)
|
||||
.concat(Object.keys(tree.folder2))
|
||||
.concat(Object.keys(tree.folder3));
|
||||
|
||||
for (const t of testCases) {
|
||||
const file: File = await fileModel.loadByName(t);
|
||||
const path = await fileModel.itemFullPath(file);
|
||||
const fileBack: File = await fileModel.entityFromItemId(path);
|
||||
expect(file.id).toBe(fileBack.id);
|
||||
}
|
||||
|
||||
const rootPath = await fileModel.itemFullPath(await fileModel.userRootFile());
|
||||
expect(rootPath).toBe('root');
|
||||
const fileBack: File = await fileModel.entityFromItemId(rootPath);
|
||||
expect(fileBack.id).toBe(rootId);
|
||||
});
|
||||
|
||||
});
|
||||
376
packages/server/src/models/FileModel.ts
Normal file
376
packages/server/src/models/FileModel.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import BaseModel, { ValidateOptions, SaveOptions, DeleteOptions } from './BaseModel';
|
||||
import { File, ItemType, databaseSchema } from '../db';
|
||||
import { ErrorForbidden, ErrorUnprocessableEntity, ErrorNotFound, ErrorBadRequest, ErrorConflict } from '../utils/errors';
|
||||
import uuidgen from '../utils/uuidgen';
|
||||
import { splitItemPath, filePathInfo } from '../utils/routeUtils';
|
||||
import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||
|
||||
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
|
||||
export interface PaginatedFiles extends PaginatedResults {
|
||||
items: File[];
|
||||
}
|
||||
|
||||
export interface EntityFromItemIdOptions {
|
||||
mustExist?: boolean;
|
||||
}
|
||||
|
||||
export default class FileModel extends BaseModel {
|
||||
|
||||
private readonly reservedCharacters = ['/', '\\', '*', '<', '>', '?', ':', '|', '#', '%'];
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'files';
|
||||
}
|
||||
|
||||
protected get trackChanges(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected get itemType(): ItemType {
|
||||
return ItemType.File;
|
||||
}
|
||||
|
||||
protected get hasParentId(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async userRootFile(): Promise<File> {
|
||||
const file: File = await this.db<File>(this.tableName).select(...this.defaultFields).from(this.tableName).where({
|
||||
'owner_id': this.userId,
|
||||
'is_root': 1,
|
||||
}).first();
|
||||
if (file) await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async userRootFileId(): Promise<string> {
|
||||
const r = await this.userRootFile();
|
||||
return r ? r.id : '';
|
||||
}
|
||||
|
||||
private async specialDirId(dirname: string): Promise<string> {
|
||||
if (dirname === 'root') return this.userRootFileId();
|
||||
return null; // Not a special dir
|
||||
}
|
||||
|
||||
public async itemFullPath(item: File): Promise<string> {
|
||||
const segments: string[] = [];
|
||||
while (item) {
|
||||
if (item.is_root) break;
|
||||
segments.splice(0, 0, item.name);
|
||||
item = item.parent_id ? await this.load(item.parent_id) : null;
|
||||
}
|
||||
|
||||
return segments.length ? (`root:/${segments.join('/')}:`) : 'root';
|
||||
}
|
||||
|
||||
public async entityFromItemId(idOrPath: string, options: EntityFromItemIdOptions = {}): Promise<File> {
|
||||
options = { mustExist: true, ...options };
|
||||
|
||||
const specialDirId = await this.specialDirId(idOrPath);
|
||||
|
||||
if (specialDirId) {
|
||||
return { id: specialDirId };
|
||||
} else if (idOrPath.indexOf(':') < 0) {
|
||||
return { id: idOrPath };
|
||||
} else {
|
||||
// When this input is a path, there can be two cases:
|
||||
// - A path to an existing file - in which case we return the file
|
||||
// - A path to a file that needs to be created - in which case we
|
||||
// return a file with all the relevant properties populated. This
|
||||
// file might then be created by the caller.
|
||||
// The caller can check file.id to see if it's a new or existing file.
|
||||
// In both cases the directories before the filename must exist.
|
||||
|
||||
const fileInfo = filePathInfo(idOrPath);
|
||||
const parentFiles = await this.pathToFiles(fileInfo.dirname);
|
||||
const parentId = parentFiles[parentFiles.length - 1].id;
|
||||
|
||||
if (!fileInfo.basename) {
|
||||
const specialDirId = await this.specialDirId(fileInfo.dirname);
|
||||
// The path simply refers to a special directory. Can happen
|
||||
// for example with a path like `root:/:` (which is the same
|
||||
// as just `root`).
|
||||
if (specialDirId) return { id: specialDirId };
|
||||
}
|
||||
|
||||
// This is an existing file
|
||||
const existingFile = await this.fileByName(parentId, fileInfo.basename);
|
||||
if (existingFile) return { id: existingFile.id };
|
||||
|
||||
if (options.mustExist) throw new ErrorNotFound(`file not found: ${idOrPath}`);
|
||||
|
||||
// This is a potentially new file
|
||||
return {
|
||||
name: fileInfo.basename,
|
||||
parent_id: parentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected get defaultFields(): string[] {
|
||||
return Object.keys(databaseSchema[this.tableName]).filter(f => f !== 'content');
|
||||
}
|
||||
|
||||
private async fileByName(parentId: string, name: string): Promise<File> {
|
||||
return this.db<File>(this.tableName).select(...this.defaultFields).where({
|
||||
parent_id: parentId,
|
||||
name: name,
|
||||
}).first();
|
||||
}
|
||||
|
||||
protected async validate(object: File, options: ValidateOptions = {}): Promise<File> {
|
||||
const file: File = object;
|
||||
|
||||
const mustBeFile = options.rules.mustBeFile === true;
|
||||
|
||||
if (options.isNew) {
|
||||
if (!file.is_root && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
if (file.is_directory && mustBeFile) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
} else {
|
||||
if ('name' in file && !file.name) throw new ErrorUnprocessableEntity('name cannot be empty');
|
||||
if ('is_directory' in file) throw new ErrorUnprocessableEntity('cannot turn a file into a directory or vice-versa');
|
||||
|
||||
if (mustBeFile && !('is_directory' in file)) {
|
||||
const existingFile = await this.load(file.id);
|
||||
if (existingFile.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
} else {
|
||||
if (file.is_directory) throw new ErrorUnprocessableEntity('item must not be a directory');
|
||||
}
|
||||
}
|
||||
|
||||
let parentId = file.parent_id;
|
||||
if (!parentId) parentId = await this.userRootFileId();
|
||||
|
||||
if ('parent_id' in file && !file.is_root) {
|
||||
const invalidParentError = function(extraInfo: string) {
|
||||
let msg = `Invalid parent ID or no permission to write to it: ${parentId}`;
|
||||
if (nodeEnv !== 'production') msg += ` (${extraInfo})`;
|
||||
return new ErrorForbidden(msg);
|
||||
};
|
||||
|
||||
if (!parentId) throw invalidParentError('No parent ID');
|
||||
|
||||
try {
|
||||
const parentFile: File = await this.load(parentId);
|
||||
if (!parentFile) throw invalidParentError('Cannot load parent file');
|
||||
if (!parentFile.is_directory) throw invalidParentError('Specified parent is not a directory');
|
||||
await this.checkCanWritePermission(parentFile);
|
||||
} catch (error) {
|
||||
if (error.message.indexOf('Invalid parent') === 0) throw error;
|
||||
throw invalidParentError(`Unknown: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ('name' in file && !file.is_root) {
|
||||
const existingFile = await this.fileByName(parentId, file.name);
|
||||
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
||||
if (existingFile && file.id === existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}"`);
|
||||
}
|
||||
|
||||
if ('name' in file) {
|
||||
if (this.includesReservedCharacter(file.name)) throw new ErrorUnprocessableEntity(`File name may not contain any of these characters: ${this.reservedCharacters.join('')}`);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public fromApiInput(object: File): File {
|
||||
const file: File = {};
|
||||
|
||||
if ('id' in object) file.id = object.id;
|
||||
if ('name' in object) file.name = object.name;
|
||||
if ('parent_id' in object) file.parent_id = object.parent_id;
|
||||
if ('mime_type' in object) file.mime_type = object.mime_type;
|
||||
if ('is_directory' in object) file.is_directory = object.is_directory;
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public toApiOutput(object: any): any {
|
||||
if (Array.isArray(object)) {
|
||||
return object.map(f => this.toApiOutput(f));
|
||||
} else {
|
||||
const output: File = { ...object };
|
||||
delete output.content;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
public async createRootFile(): Promise<File> {
|
||||
const existingRootFile = await this.userRootFile();
|
||||
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
|
||||
|
||||
const id = uuidgen();
|
||||
|
||||
return this.save({
|
||||
id: id,
|
||||
is_directory: 1,
|
||||
is_root: 1,
|
||||
name: id, // Name must be unique so we set it to the ID
|
||||
}, {
|
||||
isNew: true,
|
||||
trackChanges: false, // Root file always exist and never changes so we don't want any change event being generated
|
||||
});
|
||||
}
|
||||
|
||||
private async checkCanReadOrWritePermissions(methodName: 'canRead' | 'canWrite', file: File | File[]): Promise<void> {
|
||||
const files = Array.isArray(file) ? file : [file];
|
||||
|
||||
if (!files.length || !files[0]) throw new ErrorNotFound();
|
||||
|
||||
const fileIds = files.map(f => f.id);
|
||||
|
||||
const permissionModel = this.models().permission();
|
||||
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.owner_id === this.userId) permissionGrantedMap[file.id] = true;
|
||||
}
|
||||
|
||||
for (const fileId in permissionGrantedMap) {
|
||||
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No read access to: ${fileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCanReadPermissions(file: File | File[]): Promise<void> {
|
||||
await this.checkCanReadOrWritePermissions('canRead', file);
|
||||
}
|
||||
|
||||
private async checkCanWritePermission(file: File): Promise<void> {
|
||||
await this.checkCanReadOrWritePermissions('canWrite', file);
|
||||
}
|
||||
|
||||
private includesReservedCharacter(path: string): boolean {
|
||||
return this.reservedCharacters.some(c => path.indexOf(c) >= 0);
|
||||
}
|
||||
|
||||
private async pathToFiles(path: string, mustExist: boolean = true): Promise<File[]> {
|
||||
const filenames = splitItemPath(path);
|
||||
const output: File[] = [];
|
||||
let parent: File = null;
|
||||
|
||||
for (let i = 0; i < filenames.length; i++) {
|
||||
const filename = filenames[i];
|
||||
let file: File = null;
|
||||
if (i === 0) {
|
||||
// For now we only support "root" as a root component, but potentially it could
|
||||
// be any special directory like "documents", "pictures", etc.
|
||||
if (filename !== 'root') throw new ErrorBadRequest(`unknown path root component: ${filename}`);
|
||||
file = await this.userRootFile();
|
||||
} else {
|
||||
file = await this.fileByName(parent.id, filename);
|
||||
}
|
||||
|
||||
if (!file && mustExist) throw new ErrorNotFound(`file not found: "${filename}" on parent "${parent ? parent.name : ''}"`);
|
||||
|
||||
output.push(file);
|
||||
parent = { ...file };
|
||||
}
|
||||
|
||||
if (!output.length && mustExist) throw new ErrorBadRequest(`path without a base directory: ${path}`);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Mostly makes sense for testing/debugging because the filename would
|
||||
// have to globally unique, which is not a requirement.
|
||||
public async loadByName(name: string): Promise<File> {
|
||||
const file: File = await this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.where({ name: name })
|
||||
.andWhere({ owner_id: this.userId })
|
||||
.first();
|
||||
if (!file) throw new ErrorNotFound(`No such file: ${name}`);
|
||||
await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async loadWithContent(id: string): Promise<any> {
|
||||
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
|
||||
if (!file) return null;
|
||||
await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async loadByIds(ids: string[]): Promise<File[]> {
|
||||
const files: File[] = await super.loadByIds(ids);
|
||||
if (!files.length) return [];
|
||||
await this.checkCanReadPermissions(files);
|
||||
return files;
|
||||
}
|
||||
|
||||
public async load(id: string): Promise<File> {
|
||||
const file: File = await super.load(id);
|
||||
if (!file) return null;
|
||||
await this.checkCanReadPermissions(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async save(object: File, options: SaveOptions = {}): Promise<File> {
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
const file: File = { ... object };
|
||||
|
||||
if ('content' in file) file.size = file.content ? file.content.byteLength : 0;
|
||||
|
||||
if (isNew) {
|
||||
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
|
||||
|
||||
// Even if there's no content, set the mime type based on the extension
|
||||
if (!file.is_directory) file.mime_type = mimeUtils.fromFilename(file.name);
|
||||
|
||||
// Make sure it's not NULL, which is not allowed
|
||||
if (!file.mime_type) file.mime_type = '';
|
||||
|
||||
file.owner_id = this.userId;
|
||||
}
|
||||
|
||||
return super.save(file, options);
|
||||
}
|
||||
|
||||
public async childrenCount(id: string): Promise<number> {
|
||||
const parent = await this.load(id);
|
||||
await this.checkCanReadPermissions(parent);
|
||||
const r = await this.db(this.tableName).select('id').where('parent_id', id).count('id', { as: 'total' });
|
||||
return r.length && r[0].total ? r[0].total : 0;
|
||||
}
|
||||
|
||||
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
|
||||
const parent = await this.load(id);
|
||||
await this.checkCanReadPermissions(parent);
|
||||
return paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
|
||||
}
|
||||
|
||||
private async childrenIds(id: string): Promise<string[]> {
|
||||
const output = await this.db(this.tableName).select('id').where('parent_id', id);
|
||||
return output.map(r => r.id);
|
||||
}
|
||||
|
||||
public async delete(id: string, options: DeleteOptions = {}): Promise<void> {
|
||||
const file: File = await this.load(id);
|
||||
if (!file) return;
|
||||
await this.checkCanWritePermission(file);
|
||||
|
||||
const canDeleteRoot = !!options.validationRules && !!options.validationRules.canDeleteRoot;
|
||||
|
||||
if (id === await this.userRootFileId() && !canDeleteRoot) throw new ErrorForbidden('the root directory may not be deleted');
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
await this.models().permission().deleteByFileId(id);
|
||||
|
||||
if (file.is_directory) {
|
||||
const childrenIds = await this.childrenIds(file.id);
|
||||
for (const childId of childrenIds) {
|
||||
await this.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
await super.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
82
packages/server/src/models/PermissionModel.ts
Normal file
82
packages/server/src/models/PermissionModel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import BaseModel from './BaseModel';
|
||||
import { Permission, ItemType, User, Uuid } from '../db';
|
||||
|
||||
enum ReadOrWriteKeys {
|
||||
CanRead = 'can_read',
|
||||
CanWrite = 'can_write',
|
||||
}
|
||||
|
||||
// Tells whether the given item has the permission to do the required operation
|
||||
// (can be read or write).
|
||||
export type PermissionGrantedMap = Record<Uuid, boolean>;
|
||||
|
||||
export type PermissionMap = Record<Uuid, Permission[]>;
|
||||
|
||||
export default class PermissionModel extends BaseModel {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'permissions';
|
||||
}
|
||||
|
||||
private async filePermissions(fileId: string, userId: string = null): Promise<Permission[]> {
|
||||
const p = await this.filesPermissions([fileId], userId);
|
||||
return p[fileId];
|
||||
}
|
||||
|
||||
private async filesPermissions(fileIds: string[], userId: string = null): Promise<PermissionMap> {
|
||||
const p: Permission = {
|
||||
item_type: ItemType.File,
|
||||
};
|
||||
|
||||
if (userId) p.user_id = userId;
|
||||
|
||||
const permissions: Permission[] = await this.db<Permission>(this.tableName).where(p).whereIn('item_id', fileIds).select();
|
||||
const output: PermissionMap = {};
|
||||
|
||||
for (const fileId of fileIds) {
|
||||
output[fileId] = [];
|
||||
}
|
||||
|
||||
for (const permission of permissions) {
|
||||
output[permission.item_id].push(permission);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private async canReadOrWrite(fileIds: string[], userId: string, method: ReadOrWriteKeys): Promise<PermissionGrantedMap> {
|
||||
const output: PermissionGrantedMap = {};
|
||||
|
||||
if (!fileIds.length) throw new Error('No files specified');
|
||||
if (!userId) throw new Error('No user specified');
|
||||
|
||||
const permissionMap = await this.filesPermissions(fileIds, userId);
|
||||
const userModel = this.models().user({ userId: userId });
|
||||
const user: User = await userModel.load(userId);
|
||||
|
||||
for (const fileId in permissionMap) {
|
||||
const permissions = permissionMap[fileId];
|
||||
output[fileId] = !!user.is_admin || !!permissions.find(p => !!p[method]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async canRead(fileId: string | string[], userId: string): Promise<PermissionGrantedMap> {
|
||||
fileId = Array.isArray(fileId) ? fileId : [fileId];
|
||||
return this.canReadOrWrite(fileId, userId, ReadOrWriteKeys.CanRead);
|
||||
}
|
||||
|
||||
public async canWrite(fileId: string | string[], userId: string): Promise<PermissionGrantedMap> {
|
||||
fileId = Array.isArray(fileId) ? fileId : [fileId];
|
||||
return this.canReadOrWrite(fileId, userId, ReadOrWriteKeys.CanWrite);
|
||||
}
|
||||
|
||||
public async deleteByFileId(fileId: string): Promise<void> {
|
||||
const permissions = await this.filePermissions(fileId);
|
||||
if (!permissions.length) return;
|
||||
const ids = permissions.map(m => m.id);
|
||||
await super.delete(ids);
|
||||
}
|
||||
|
||||
}
|
||||
17
packages/server/src/models/SessionModel.ts
Normal file
17
packages/server/src/models/SessionModel.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import BaseModel from './BaseModel';
|
||||
import { User, Session } from '../db';
|
||||
|
||||
export default class SessionModel extends BaseModel {
|
||||
|
||||
protected get tableName(): string {
|
||||
return 'sessions';
|
||||
}
|
||||
|
||||
public async sessionUser(sessionId: string): Promise<User> {
|
||||
const session: Session = await this.load(sessionId);
|
||||
if (!session) return null;
|
||||
const userModel = this.models().user({ userId: session.user_id });
|
||||
return userModel.load(session.user_id);
|
||||
}
|
||||
|
||||
}
|
||||
111
packages/server/src/models/UserModel.ts
Normal file
111
packages/server/src/models/UserModel.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import BaseModel, { SaveOptions, ValidateOptions } from './BaseModel';
|
||||
import { User } from '../db';
|
||||
import * as auth from '../utils/auth';
|
||||
import { ErrorUnprocessableEntity, ErrorForbidden } from '../utils/errors';
|
||||
|
||||
export default class UserModel extends BaseModel {
|
||||
|
||||
public get tableName(): string {
|
||||
return 'users';
|
||||
}
|
||||
|
||||
public async loadByEmail(email: string): Promise<User> {
|
||||
const user: User = { email: email };
|
||||
return this.db<User>(this.tableName).where(user).first();
|
||||
}
|
||||
|
||||
public fromApiInput(object: User): User {
|
||||
const user: User = {};
|
||||
|
||||
if ('id' in object) user.id = object.id;
|
||||
if ('email' in object) user.email = object.email;
|
||||
if ('password' in object) user.password = object.password;
|
||||
if ('is_admin' in object) user.is_admin = object.is_admin;
|
||||
if ('full_name' in object) user.full_name = object.full_name;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public toApiOutput(object: User): User {
|
||||
const output: User = { ...object };
|
||||
delete output.password;
|
||||
return output;
|
||||
}
|
||||
|
||||
protected async validate(object: User, options: ValidateOptions = {}): Promise<User> {
|
||||
const user: User = await super.validate(object, options);
|
||||
|
||||
const owner: User = await this.load(this.userId);
|
||||
|
||||
if (options.isNew) {
|
||||
if (!owner.is_admin) throw new ErrorForbidden('non-admin user cannot create a new user');
|
||||
if (!user.email) throw new ErrorUnprocessableEntity('email must be set');
|
||||
if (!user.password) throw new ErrorUnprocessableEntity('password must be set');
|
||||
} else {
|
||||
if (!owner.is_admin && user.id !== owner.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
||||
if ('email' in user && !user.email) throw new ErrorUnprocessableEntity('email must be set');
|
||||
if ('password' in user && !user.password) throw new ErrorUnprocessableEntity('password must be set');
|
||||
if (!owner.is_admin && 'is_admin' in user) throw new ErrorForbidden('non-admin user cannot make a user an admin');
|
||||
if (owner.is_admin && owner.id === user.id && 'is_admin' in user && !user.is_admin) throw new ErrorUnprocessableEntity('non-admin user cannot remove admin bit from themselves');
|
||||
}
|
||||
|
||||
if ('email' in user) {
|
||||
const existingUser = await this.loadByEmail(user.email);
|
||||
if (existingUser && existingUser.id !== user.id) throw new ErrorUnprocessableEntity(`there is already a user with this email: ${user.email}`);
|
||||
if (!this.validateEmail(user.email)) throw new ErrorUnprocessableEntity(`Invalid email: ${user.email}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private validateEmail(email: string): boolean {
|
||||
const s = email.split('@');
|
||||
if (s.length !== 2) return false;
|
||||
return !!s[0].length && !!s[1].length;
|
||||
}
|
||||
|
||||
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
|
||||
if (!this.userId) throw new ErrorForbidden('no user is active');
|
||||
|
||||
if (userId === this.userId) return;
|
||||
|
||||
const owner = await this.load(this.userId);
|
||||
if (!owner.is_admin) throw new ErrorForbidden();
|
||||
}
|
||||
|
||||
public async load(id: string): Promise<User> {
|
||||
await this.checkIsOwnerOrAdmin(id);
|
||||
return super.load(id);
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
await this.checkIsOwnerOrAdmin(id);
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
const fileModel = this.models().file({ userId: id });
|
||||
const rootFile = await fileModel.userRootFile();
|
||||
await fileModel.delete(rootFile.id, { validationRules: { canDeleteRoot: true } });
|
||||
await super.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
public async save(object: User, options: SaveOptions = {}): Promise<User> {
|
||||
const isNew = await this.isNew(object, options);
|
||||
|
||||
let newUser = { ...object };
|
||||
|
||||
if (isNew && newUser.password) newUser.password = auth.hashPassword(newUser.password);
|
||||
|
||||
await this.withTransaction(async () => {
|
||||
newUser = await super.save(newUser, options);
|
||||
|
||||
if (isNew) {
|
||||
const fileModel = this.models().file({ userId: newUser.id });
|
||||
await fileModel.createRootFile();
|
||||
}
|
||||
});
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
}
|
||||
101
packages/server/src/models/factory.ts
Normal file
101
packages/server/src/models/factory.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Each method of this class returns a new model instance, which can be
|
||||
// used to manipulate the database.
|
||||
//
|
||||
// These instances should be used within the current function, then
|
||||
// **discarded**. The caller in particular should not keep a copy of the
|
||||
// model and re-use it across multiple calls as doing so might cause issues
|
||||
// with the way transactions are managed, especially when concurrency is
|
||||
// involved.
|
||||
//
|
||||
// If a copy of the model is kept, the following could happen:
|
||||
//
|
||||
// - Async function1 calls some model function that initiates a transaction
|
||||
// - Async function2, in parallel, calls a function that also initiates a
|
||||
// transaction.
|
||||
//
|
||||
// Because of this, the transaction stack in BaseModel will be out of
|
||||
// order, and function2 might pop the transaction of function1 or
|
||||
// vice-versa. Possibly also commit or rollback the transaction of the
|
||||
// other function.
|
||||
//
|
||||
// For that reason, models should be used in a linear way, with each
|
||||
// function call being awaited before starting the next one.
|
||||
//
|
||||
// If multiple parallel calls are needed, multiple models should be
|
||||
// created, one for each "thread".
|
||||
//
|
||||
// Creating a model is cheap, or should be, so it is not an issue to create
|
||||
// and destroy them frequently.
|
||||
//
|
||||
// Perhaps all this could be enforced in code, but not clear how.
|
||||
|
||||
// So this is GOOD:
|
||||
|
||||
// class FileController {
|
||||
// public async deleteFile(id:string) {
|
||||
// const fileModel = this.models.file();
|
||||
// await fileModel.delete(id);
|
||||
// }
|
||||
// }
|
||||
|
||||
// This is BAD:
|
||||
|
||||
// class FileController {
|
||||
//
|
||||
// private fileModel;
|
||||
//
|
||||
// public constructor() {
|
||||
// // BAD - Don't keep and re-use a copy of it!
|
||||
// this.fileModel = this.models.file();
|
||||
// }
|
||||
//
|
||||
// public async deleteFile(id:string) {
|
||||
// await this.fileModel.delete(id);
|
||||
// }
|
||||
// }
|
||||
|
||||
import { DbConnection } from '../db';
|
||||
import ApiClientModel from './ApiClientModel';
|
||||
import { ModelOptions } from './BaseModel';
|
||||
import FileModel from './FileModel';
|
||||
import UserModel from './UserModel';
|
||||
import PermissionModel from './PermissionModel';
|
||||
import SessionModel from './SessionModel';
|
||||
import ChangeModel from './ChangeModel';
|
||||
|
||||
export class Models {
|
||||
|
||||
private db_: DbConnection;
|
||||
|
||||
public constructor(db: DbConnection) {
|
||||
this.db_ = db;
|
||||
}
|
||||
|
||||
public file(options: ModelOptions = null) {
|
||||
return new FileModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
|
||||
public user(options: ModelOptions = null) {
|
||||
return new UserModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
|
||||
public apiClient(options: ModelOptions = null) {
|
||||
return new ApiClientModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
|
||||
public permission(options: ModelOptions = null) {
|
||||
return new PermissionModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
|
||||
public session(options: ModelOptions = null) {
|
||||
return new SessionModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
|
||||
public change(options: ModelOptions = null) {
|
||||
return new ChangeModel(this.db_, newModelFactory, options);
|
||||
}
|
||||
}
|
||||
|
||||
export default function newModelFactory(db: DbConnection): Models {
|
||||
return new Models(db);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user