You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Server: Add Joplin Server package (#1872)
This commit is contained in:
		
							
								
								
									
										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 | ||||
							
								
								
									
										208
									
								
								.eslintignore
									
									
									
									
									
								
							
							
						
						
									
										208
									
								
								.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,202 @@ 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/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/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/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': { | ||||
|   | ||||
							
								
								
									
										208
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										208
									
								
								.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,202 @@ 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/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/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/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" ] | ||||
							
								
								
									
										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 | ||||
| @@ -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": { | ||||
|   | ||||
| @@ -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 }; | ||||
|   | ||||
| @@ -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; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										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()); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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/"] | ||||
| } | ||||
							
								
								
									
										8070
									
								
								packages/server/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										8070
									
								
								packages/server/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										44
									
								
								packages/server/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/server/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| { | ||||
|   "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": { | ||||
|     "@joplin/lib": "^1.0.9", | ||||
|     "bcryptjs": "^2.4.3", | ||||
|     "bulma": "^0.9.1", | ||||
|     "bulma-prefers-dark": "^0.1.0-beta.0", | ||||
|     "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
											
										
									
								
							
							
								
								
									
										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; | ||||
| } | ||||
							
								
								
									
										27
									
								
								packages/server/public/css/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/server/public/css/main.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| * { | ||||
| 	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; | ||||
| } | ||||
							
								
								
									
										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); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										159
									
								
								packages/server/src/app.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								packages/server/src/app.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| // 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) { | ||||
| 		appLogger().error(error); | ||||
| 		ctx.response.status = error.httpCode ? error.httpCode : 500; | ||||
|  | ||||
| 		const responseFormat = routeResponseFormat(match); | ||||
|  | ||||
| 		if (responseFormat === RouteResponseFormat.Html) { | ||||
| 			ctx.response.set('Content-Type', 'text/html'); | ||||
| 			const view: View = { | ||||
| 				name: 'error', | ||||
| 				path: 'index/error', | ||||
| 				content: { | ||||
| 					error, | ||||
| 				}, | ||||
| 			}; | ||||
| 			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); | ||||
| 	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, fileId: 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(fileId); | ||||
| 		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); | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										55
									
								
								packages/server/src/controllers/factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								packages/server/src/controllers/factory.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| 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'; | ||||
|  | ||||
| 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_); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| export default function(models: Models) { | ||||
| 	return new Controllers(models); | ||||
| } | ||||
							
								
								
									
										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; | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										345
									
								
								packages/server/src/models/FileModel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								packages/server/src/models/FileModel.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,345 @@ | ||||
| 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 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; | ||||
| 	} | ||||
|  | ||||
| 	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 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); | ||||
| } | ||||
							
								
								
									
										72
									
								
								packages/server/src/models/utils/pagination.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								packages/server/src/models/utils/pagination.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| import { expectThrow } from '../../utils/testUtils'; | ||||
| import { defaultPagination, Pagination, requestPagination } from './pagination'; | ||||
|  | ||||
| describe('pagination', function() { | ||||
|  | ||||
| 	test('should create options from request query parameters', async function() { | ||||
| 		const d = defaultPagination(); | ||||
|  | ||||
| 		const testCases: any = [ | ||||
| 			[ | ||||
| 				null, | ||||
| 				d, | ||||
| 			], | ||||
| 			[ | ||||
| 				{ | ||||
| 					order_by: 'title', | ||||
| 				}, | ||||
| 				{ | ||||
| 					...d, | ||||
| 					order: [{ | ||||
| 						by: 'title', | ||||
| 						dir: d.order[0].dir, | ||||
| 					}], | ||||
| 				}, | ||||
| 			], | ||||
| 			[ | ||||
| 				{ | ||||
| 					order_by: 'title', | ||||
| 					order_dir: 'asc', | ||||
| 				}, | ||||
| 				{ | ||||
| 					...d, | ||||
| 					order: [{ | ||||
| 						by: 'title', | ||||
| 						dir: 'asc', | ||||
| 					}], | ||||
| 				}, | ||||
| 			], | ||||
| 			[ | ||||
| 				{ | ||||
| 					limit: 55, | ||||
| 				}, | ||||
| 				{ | ||||
| 					...d, | ||||
| 					limit: 55, | ||||
| 				}, | ||||
| 			], | ||||
| 			[ | ||||
| 				{ | ||||
| 					page: 3, | ||||
| 				}, | ||||
| 				{ | ||||
| 					...d, | ||||
| 					page: 3, | ||||
| 				}, | ||||
| 			], | ||||
| 		]; | ||||
|  | ||||
| 		for (const t of testCases) { | ||||
| 			const input: any = t[0]; | ||||
| 			const expected: Pagination = t[1]; | ||||
| 			const actual: Pagination = requestPagination(input); | ||||
|  | ||||
| 			expect(actual).toEqual(expected); | ||||
| 		} | ||||
|  | ||||
| 		await expectThrow(async () => requestPagination({ order_dir: 'ASC' })); | ||||
| 		await expectThrow(async () => requestPagination({ order_dir: 'DESC' })); | ||||
| 		await expectThrow(async () => requestPagination({ page: 0 })); | ||||
| 	}); | ||||
|  | ||||
| }); | ||||
							
								
								
									
										122
									
								
								packages/server/src/models/utils/pagination.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								packages/server/src/models/utils/pagination.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| import { ErrorBadRequest } from '../../utils/errors'; | ||||
| import { decodeBase64, encodeBase64 } from '../../utils/base64'; | ||||
| import { ChangePagination, defaultChangePagination } from '../ChangeModel'; | ||||
| import Knex = require('knex'); | ||||
|  | ||||
| export enum PaginationOrderDir { | ||||
| 	ASC = 'asc', | ||||
| 	DESC = 'desc', | ||||
| } | ||||
|  | ||||
| export interface PaginationOrder { | ||||
| 	by: string; | ||||
| 	dir: PaginationOrderDir; | ||||
| } | ||||
|  | ||||
| export interface Pagination { | ||||
| 	limit?: number; | ||||
| 	order?: PaginationOrder[]; | ||||
| 	page?: number; | ||||
| 	cursor?: string; | ||||
| } | ||||
|  | ||||
| export interface PaginatedResults { | ||||
| 	items: any[]; | ||||
| 	has_more: boolean; | ||||
| 	cursor?: string; | ||||
| } | ||||
|  | ||||
| const pageMaxSize = 1000; | ||||
| const defaultOrderField = 'updated_time'; | ||||
| const defaultOrderDir = PaginationOrderDir.DESC; | ||||
|  | ||||
| export function defaultPagination(): Pagination { | ||||
| 	return { | ||||
| 		limit: pageMaxSize, | ||||
| 		order: [ | ||||
| 			{ | ||||
| 				by: defaultOrderField, | ||||
| 				dir: defaultOrderDir, | ||||
| 			}, | ||||
| 		], | ||||
| 		page: 1, | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| function dbOffset(pagination: Pagination): number { | ||||
| 	return pagination.limit * (pagination.page - 1); | ||||
| } | ||||
|  | ||||
| function requestPaginationOrder(query: any): PaginationOrder[] { | ||||
| 	const orderBy: string = 'order_by' in query ? query.order_by : defaultOrderField; | ||||
| 	const orderDir: PaginationOrderDir = 'order_dir' in query ? query.order_dir : defaultOrderDir; | ||||
|  | ||||
| 	if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(orderDir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${orderDir}`); | ||||
|  | ||||
| 	return [{ | ||||
| 		by: orderBy, | ||||
| 		dir: orderDir, | ||||
| 	}]; | ||||
| } | ||||
|  | ||||
| function validatePagination(p: Pagination): Pagination { | ||||
| 	if (p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`); | ||||
| 	if (p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`); | ||||
|  | ||||
| 	for (const o of p.order) { | ||||
| 		if (![PaginationOrderDir.ASC, PaginationOrderDir.DESC].includes(o.dir)) throw new ErrorBadRequest(`Invalid order_dir parameter: ${o.dir}`); | ||||
| 	} | ||||
|  | ||||
| 	return p; | ||||
| } | ||||
|  | ||||
| function processCursor(pagination: Pagination): Pagination { | ||||
| 	// If a cursor is present, we parse it and move to the next page. | ||||
| 	if (pagination.cursor) { | ||||
| 		const p = validatePagination(JSON.parse(decodeBase64(pagination.cursor))); | ||||
| 		p.page++; | ||||
| 		return p; | ||||
| 	} | ||||
|  | ||||
| 	return pagination as Pagination; | ||||
| } | ||||
|  | ||||
| export function requestPagination(query: any): Pagination { | ||||
| 	if (!query) return defaultPagination(); | ||||
|  | ||||
| 	if ('cursor' in query) { | ||||
| 		return processCursor(query); | ||||
| 	} | ||||
|  | ||||
| 	const limit = 'limit' in query ? query.limit : pageMaxSize; | ||||
| 	const order: PaginationOrder[] = requestPaginationOrder(query); | ||||
| 	const page: number = 'page' in query ? query.page : 1; | ||||
|  | ||||
| 	return validatePagination({ limit, order, page }); | ||||
| } | ||||
|  | ||||
| export function requestChangePagination(query: any): ChangePagination { | ||||
| 	if (!query) return defaultChangePagination(); | ||||
|  | ||||
| 	const output: ChangePagination = {}; | ||||
| 	if ('limit' in query) output.limit = query.limit; | ||||
| 	if ('cursor' in query) output.cursor = query.cursor; | ||||
| 	return output; | ||||
| } | ||||
|  | ||||
| export async function paginateDbQuery(query: Knex.QueryBuilder, pagination: Pagination): Promise<PaginatedResults> { | ||||
| 	pagination = processCursor(pagination); | ||||
|  | ||||
| 	const items = await query | ||||
| 		.orderBy(pagination.order[0].by, pagination.order[0].dir) | ||||
| 		.offset(dbOffset(pagination)) | ||||
| 		.limit(pagination.limit); | ||||
|  | ||||
| 	const hasMore = items.length >= pagination.limit; | ||||
|  | ||||
| 	return { | ||||
| 		items, | ||||
| 		has_more: hasMore, | ||||
| 		cursor: hasMore ? encodeBase64(JSON.stringify(pagination)) : null, | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										87
									
								
								packages/server/src/routes/api/files.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								packages/server/src/routes/api/files.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| import { ErrorNotFound, ErrorMethodNotAllowed, ErrorBadRequest } from '../../utils/errors'; | ||||
| import { File } from '../../db'; | ||||
| import { bodyFields, formParse, headerSessionId } from '../../utils/requestUtils'; | ||||
| import { SubPath, Route, ResponseType, Response } from '../../utils/routeUtils'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import * as fs from 'fs-extra'; | ||||
| import { requestChangePagination, requestPagination } from '../../models/utils/pagination'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(path: SubPath, ctx: AppContext) { | ||||
| 		const fileController = ctx.controllers.apiFile(); | ||||
|  | ||||
| 		// console.info(`${ctx.method} ${path.id}${path.link ? `/${path.link}` : ''}`); | ||||
|  | ||||
| 		if (!path.link) { | ||||
| 			if (ctx.method === 'GET') { | ||||
| 				return fileController.getFile(headerSessionId(ctx.headers), path.id); | ||||
| 			} | ||||
|  | ||||
| 			if (ctx.method === 'PATCH') { | ||||
| 				return fileController.patchFile(headerSessionId(ctx.headers), path.id, await bodyFields(ctx.req)); | ||||
| 			} | ||||
|  | ||||
| 			if (ctx.method === 'DELETE') { | ||||
| 				return fileController.deleteFile(headerSessionId(ctx.headers), path.id); | ||||
| 			} | ||||
|  | ||||
| 			throw new ErrorMethodNotAllowed(); | ||||
| 		} | ||||
|  | ||||
| 		if (path.link === 'content') { | ||||
| 			if (ctx.method === 'GET') { | ||||
| 				const koaResponse = ctx.response; | ||||
| 				const file: File = await fileController.getFileContent(headerSessionId(ctx.headers), path.id); | ||||
| 				koaResponse.body = file.content; | ||||
| 				koaResponse.set('Content-Type', file.mime_type); | ||||
| 				koaResponse.set('Content-Length', file.size.toString()); | ||||
| 				return new Response(ResponseType.KoaResponse, koaResponse); | ||||
| 			} | ||||
|  | ||||
| 			if (ctx.method === 'PUT') { | ||||
| 				const result = await formParse(ctx.req); | ||||
| 				if (!result?.files?.file) throw new ErrorBadRequest('File data is missing'); | ||||
| 				const buffer = await fs.readFile(result.files.file.path); | ||||
| 				return fileController.putFileContent(headerSessionId(ctx.headers), path.id, buffer); | ||||
| 			} | ||||
|  | ||||
| 			if (ctx.method === 'DELETE') { | ||||
| 				return fileController.deleteFileContent(headerSessionId(ctx.headers), path.id); | ||||
| 			} | ||||
|  | ||||
| 			throw new ErrorMethodNotAllowed(); | ||||
| 		} | ||||
|  | ||||
| 		if (path.link === 'delta') { | ||||
| 			if (ctx.method === 'GET') { | ||||
| 				return fileController.getDelta( | ||||
| 					headerSessionId(ctx.headers), | ||||
| 					path.id, | ||||
| 					requestChangePagination(ctx.query) | ||||
| 				); | ||||
| 			} | ||||
|  | ||||
| 			throw new ErrorMethodNotAllowed(); | ||||
| 		} | ||||
|  | ||||
| 		if (path.link === 'children') { | ||||
| 			if (ctx.method === 'GET') { | ||||
| 				return fileController.getChildren(headerSessionId(ctx.headers), path.id, requestPagination(ctx.query)); | ||||
| 			} | ||||
|  | ||||
| 			if (ctx.method === 'POST') { | ||||
| 				return fileController.postChild(headerSessionId(ctx.headers), path.id, await bodyFields(ctx.req)); | ||||
| 			} | ||||
|  | ||||
| 			throw new ErrorMethodNotAllowed(); | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorNotFound(`Invalid link: ${path.link}`); | ||||
| 	}, | ||||
|  | ||||
| 	needsBodyMiddleware: true, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										11
									
								
								packages/server/src/routes/api/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/server/src/routes/api/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Route } from '../../utils/routeUtils'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function() { | ||||
| 		return { status: 'ok', message: 'Joplin Server is running' }; | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										11
									
								
								packages/server/src/routes/api/ping.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/server/src/routes/api/ping.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { Route } from '../../utils/routeUtils'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function() { | ||||
| 		return { status: 'ok', message: 'Joplin Server is running' }; | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										23
									
								
								packages/server/src/routes/api/sessions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/server/src/routes/api/sessions.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import { SubPath, Route } from '../../utils/routeUtils'; | ||||
| import { ErrorNotFound } from '../../utils/errors'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { bodyFields } from '../../utils/requestUtils'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(path: SubPath, ctx: AppContext) { | ||||
| 		if (!path.link) { | ||||
| 			if (ctx.method === 'POST') { | ||||
| 				const user =  await bodyFields(ctx.req); | ||||
| 				const sessionController = ctx.controllers.apiSession(); | ||||
| 				const session = await sessionController.authenticate(user.email, user.password); | ||||
| 				return { id: session.id }; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorNotFound(`Invalid link: ${path.link}`); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										60
									
								
								packages/server/src/routes/default.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/server/src/routes/default.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import * as Koa from 'koa'; | ||||
| import { SubPath, Route, Response, ResponseType } from '../utils/routeUtils'; | ||||
| import { ErrorNotFound, ErrorForbidden } from '../utils/errors'; | ||||
| import { dirname, normalize } from 'path'; | ||||
| import { pathExists } from 'fs-extra'; | ||||
| import * as fs from 'fs-extra'; | ||||
| const { mime } = require('@joplin/lib/mime-utils.js'); | ||||
|  | ||||
| const publicDir = `${dirname(dirname(__dirname))}/public`; | ||||
|  | ||||
| interface PathToFileMap { | ||||
| 	[path: string]: string; | ||||
| } | ||||
|  | ||||
| // Most static assets should be in /public, but for those that are not, for | ||||
| // example if they are in node_modules, use the map below | ||||
| const pathToFileMap: PathToFileMap = { | ||||
| 	'css/bulma.min.css': 'node_modules/bulma/css/bulma.min.css', | ||||
| 	'css/bulma-prefers-dark.min.css': 'node_modules/bulma-prefers-dark/css/bulma-prefers-dark.min.css', | ||||
| }; | ||||
|  | ||||
| async function findLocalFile(path: string): Promise<string> { | ||||
| 	if (path in pathToFileMap) return pathToFileMap[path]; | ||||
|  | ||||
| 	let localPath = normalize(path); | ||||
| 	if (localPath.indexOf('..') >= 0) throw new ErrorNotFound(`Cannot resolve path: ${path}`); | ||||
| 	localPath = `${publicDir}/${localPath}`; | ||||
| 	if (!(await pathExists(localPath))) throw new ErrorNotFound(`Path not found: ${path}`); | ||||
|  | ||||
| 	const stat = await fs.stat(localPath); | ||||
| 	if (stat.isDirectory()) throw new ErrorForbidden(`Directory listing not allowed: ${path}`); | ||||
|  | ||||
| 	return localPath; | ||||
| } | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(path: SubPath, ctx: Koa.Context) { | ||||
|  | ||||
| 		if (ctx.method === 'GET') { | ||||
| 			const localPath = await findLocalFile(path.raw); | ||||
|  | ||||
| 			let mimeType: string = mime.fromFilename(localPath); | ||||
| 			if (!mimeType) mimeType = 'application/octet-stream'; | ||||
|  | ||||
| 			const fileContent: Buffer = await fs.readFile(localPath); | ||||
|  | ||||
| 			const koaResponse = ctx.response; | ||||
| 			koaResponse.body = fileContent; | ||||
| 			koaResponse.set('Content-Type', mimeType); | ||||
| 			koaResponse.set('Content-Length', fileContent.length.toString()); | ||||
| 			return new Response(ResponseType.KoaResponse, koaResponse); | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorNotFound(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										21
									
								
								packages/server/src/routes/index/home.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/server/src/routes/index/home.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import { SubPath, Route } from '../../utils/routeUtils'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { contextSessionId } from '../../utils/requestUtils'; | ||||
| import { ErrorMethodNotAllowed } from '../../utils/errors'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(_path: SubPath, ctx: AppContext) { | ||||
| 		const sessionId = contextSessionId(ctx); | ||||
| 		const homeController = ctx.controllers.indexHome(); | ||||
|  | ||||
| 		if (ctx.method === 'GET') { | ||||
| 			return homeController.getIndex(sessionId); | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										33
									
								
								packages/server/src/routes/index/login.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/server/src/routes/index/login.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import { SubPath, Route, redirect } from '../../utils/routeUtils'; | ||||
| import { ErrorMethodNotAllowed } from '../../utils/errors'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { formParse } from '../../utils/requestUtils'; | ||||
| import { baseUrl } from '../../config'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(_path: SubPath, ctx: AppContext) { | ||||
| 		const loginController = ctx.controllers.indexLogin(); | ||||
|  | ||||
| 		if (ctx.method === 'GET') { | ||||
| 			return loginController.getIndex(); | ||||
| 		} | ||||
|  | ||||
| 		if (ctx.method === 'POST') { | ||||
| 			try { | ||||
| 				const body = await formParse(ctx.req); | ||||
| 				const session = await ctx.controllers.apiSession().authenticate(body.fields.email, body.fields.password); | ||||
|  | ||||
| 				ctx.cookies.set('sessionId', session.id); | ||||
| 				return redirect(ctx, `${baseUrl()}/home`); | ||||
| 			} catch (error) { | ||||
| 				return loginController.getIndex(error); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										20
									
								
								packages/server/src/routes/index/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/server/src/routes/index/logout.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import { SubPath, Route, redirect } from '../../utils/routeUtils'; | ||||
| import { ErrorMethodNotAllowed } from '../../utils/errors'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { baseUrl } from '../../config'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(_path: SubPath, ctx: AppContext) { | ||||
| 		if (ctx.method === 'POST') { | ||||
| 			// TODO: also delete the session from the database | ||||
| 			ctx.cookies.set('sessionId', ''); | ||||
| 			return redirect(ctx, `${baseUrl()}/login`); | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										47
									
								
								packages/server/src/routes/index/profile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								packages/server/src/routes/index/profile.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| import { SubPath, Route, redirect } from '../../utils/routeUtils'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { contextSessionId, formParse } from '../../utils/requestUtils'; | ||||
| import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors'; | ||||
| import { User } from '../../db'; | ||||
| import { baseUrl } from '../../config'; | ||||
| import { hashPassword } from '../../utils/auth'; | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(_path: SubPath, ctx: AppContext) { | ||||
| 		const sessionId = contextSessionId(ctx); | ||||
|  | ||||
| 		if (ctx.method === 'GET') { | ||||
| 			return ctx.controllers.indexProfile().getIndex(sessionId); | ||||
| 		} | ||||
|  | ||||
| 		if (ctx.method === 'POST') { | ||||
| 			let user: User = {}; | ||||
|  | ||||
| 			try { | ||||
| 				const body = await formParse(ctx.req); | ||||
|  | ||||
| 				user = { | ||||
| 					id: body.fields.id, | ||||
| 					email: body.fields.email, | ||||
| 					full_name: body.fields.full_name, | ||||
| 				}; | ||||
|  | ||||
| 				if (body.fields.password) { | ||||
| 					if (body.fields.password !== body.fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); | ||||
| 					user.password = hashPassword(body.fields.password); | ||||
| 				} | ||||
|  | ||||
| 				await ctx.controllers.indexProfile().patchIndex(sessionId, user); | ||||
| 				return redirect(ctx, `${baseUrl()}/profile`); | ||||
| 			} catch (error) { | ||||
| 				return ctx.controllers.indexProfile().getIndex(sessionId, user, error); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										59
									
								
								packages/server/src/routes/index/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								packages/server/src/routes/index/user.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { SubPath, Route, redirect } from '../../utils/routeUtils'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { contextSessionId, formParse } from '../../utils/requestUtils'; | ||||
| import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors'; | ||||
| import { User } from '../../db'; | ||||
| import { baseUrl } from '../../config'; | ||||
|  | ||||
| function makeUser(isNew: boolean, fields: any): User { | ||||
| 	const user: User = { | ||||
| 		email: fields.email, | ||||
| 		full_name: fields.full_name, | ||||
| 	}; | ||||
|  | ||||
| 	if (fields.password) { | ||||
| 		if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); | ||||
| 		user.password = fields.password; | ||||
| 	} | ||||
|  | ||||
| 	if (!isNew) user.id = fields.id; | ||||
|  | ||||
| 	return user; | ||||
| } | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(_path: SubPath, ctx: AppContext) { | ||||
| 		const sessionId = contextSessionId(ctx); | ||||
|  | ||||
| 		// if (ctx.method === 'GET') { | ||||
| 		// 	return ctx.controllers.indexUser().getOne(sessionId); | ||||
| 		// } | ||||
|  | ||||
| 		if (ctx.method === 'POST') { | ||||
| 			const user: User = {}; | ||||
|  | ||||
| 			try { | ||||
| 				const body = await formParse(ctx.req); | ||||
| 				const fields = body.fields; | ||||
| 				const isNew = !!Number(fields.is_new); | ||||
| 				const user = makeUser(isNew, fields); | ||||
|  | ||||
| 				if (isNew) { | ||||
| 					await ctx.controllers.apiUser().postUser(sessionId, user); | ||||
| 				} else { | ||||
| 					await ctx.controllers.apiUser().patchUser(sessionId, user); | ||||
| 				} | ||||
|  | ||||
| 				return redirect(ctx, `${baseUrl()}/users`); | ||||
| 			} catch (error) { | ||||
| 				return ctx.controllers.indexProfile().getIndex(sessionId, user, error); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										69
									
								
								packages/server/src/routes/index/users.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								packages/server/src/routes/index/users.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import { SubPath, Route, redirect } from '../../utils/routeUtils'; | ||||
| import { AppContext } from '../../utils/types'; | ||||
| import { contextSessionId, formParse } from '../../utils/requestUtils'; | ||||
| import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors'; | ||||
| import { User } from '../../db'; | ||||
| import { baseUrl } from '../../config'; | ||||
|  | ||||
| function makeUser(isNew: boolean, fields: any): User { | ||||
| 	const user: User = { | ||||
| 		email: fields.email, | ||||
| 		full_name: fields.full_name, | ||||
| 	}; | ||||
|  | ||||
| 	if (fields.password) { | ||||
| 		if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); | ||||
| 		user.password = fields.password; | ||||
| 	} | ||||
|  | ||||
| 	if (!isNew) user.id = fields.id; | ||||
|  | ||||
| 	return user; | ||||
| } | ||||
|  | ||||
| const route: Route = { | ||||
|  | ||||
| 	exec: async function(path: SubPath, ctx: AppContext) { | ||||
| 		const sessionId = contextSessionId(ctx); | ||||
| 		const isNew = path.id === 'new'; | ||||
|  | ||||
| 		if (ctx.method === 'GET') { | ||||
| 			if (path.id) { | ||||
| 				return ctx.controllers.indexUser().getOne(sessionId, isNew, !isNew ? path.id : null); | ||||
| 			} else { | ||||
| 				return ctx.controllers.indexUser().getIndex(sessionId); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (ctx.method === 'POST') { | ||||
| 			let user: User = {}; | ||||
|  | ||||
| 			try { | ||||
| 				const body = await formParse(ctx.req); | ||||
| 				const fields = body.fields; | ||||
| 				user = makeUser(isNew, fields); | ||||
|  | ||||
| 				if (fields.post_button) { | ||||
| 					if (isNew) { | ||||
| 						await ctx.controllers.apiUser().postUser(sessionId, user); | ||||
| 					} else { | ||||
| 						await ctx.controllers.apiUser().patchUser(sessionId, user); | ||||
| 					} | ||||
| 				} else if (fields.delete_button) { | ||||
| 					await ctx.controllers.apiUser().deleteUser(sessionId, path.id); | ||||
| 				} else { | ||||
| 					throw new Error('Invalid form button'); | ||||
| 				} | ||||
|  | ||||
| 				return redirect(ctx, `${baseUrl()}/users`); | ||||
| 			} catch (error) { | ||||
| 				return ctx.controllers.indexUser().getOne(sessionId, isNew, user, error); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new ErrorMethodNotAllowed(); | ||||
| 	}, | ||||
|  | ||||
| }; | ||||
|  | ||||
| export default route; | ||||
							
								
								
									
										27
									
								
								packages/server/src/routes/oauth2/authorize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/server/src/routes/oauth2/authorize.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // import { ErrorMethodNotAllowed } from '../../utils/errors'; | ||||
| // import { SubPath, Route } from '../../utils/routeUtils'; | ||||
| // import { AppContext } from '../../utils/types'; | ||||
|  | ||||
| // const route: Route = { | ||||
|  | ||||
| // 	exec: async function(_: SubPath, ctx: AppContext) { | ||||
|  | ||||
| // 		const controller = ctx.controllers.oauth(); | ||||
|  | ||||
| // 		if (ctx.method === 'GET') { | ||||
| // 			return controller.getAuthorize(ctx.request.query); | ||||
| // 		} | ||||
|  | ||||
| // 		if (ctx.method === 'POST') { | ||||
| // 			return controller.postAuthorize(ctx.request.body); | ||||
| // 		} | ||||
|  | ||||
| // 		throw new ErrorMethodNotAllowed(); | ||||
| // 	}, | ||||
|  | ||||
| // 	needsBodyMiddleware: true, | ||||
| // 	// responseFormat: 'html', | ||||
|  | ||||
| // }; | ||||
|  | ||||
| // export default route; | ||||
							
								
								
									
										31
									
								
								packages/server/src/routes/routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								packages/server/src/routes/routes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import { Routes } from '../utils/routeUtils'; | ||||
|  | ||||
| import apiSessions from './api/sessions'; | ||||
| import apiPing from './api/ping'; | ||||
| import apiFiles from './api/files'; | ||||
| // import oauth2Authorize from './oauth2/authorize'; | ||||
| import indexLoginRoute from './index/login'; | ||||
| import indexLogoutRoute from './index/logout'; | ||||
| import indexHomeRoute from './index/home'; | ||||
| import indexProfileRoute from './index/profile'; | ||||
| import indexUsersRoute from './index/users'; | ||||
| import indexUserRoute from './index/user'; | ||||
| import defaultRoute from './default'; | ||||
|  | ||||
| const routes: Routes = { | ||||
| 	'api/ping': apiPing, | ||||
| 	'api/sessions': apiSessions, | ||||
| 	'api/files': apiFiles, | ||||
| 	// 'oauth2/authorize': oauth2Authorize, | ||||
|  | ||||
| 	'login': indexLoginRoute, | ||||
| 	'logout': indexLogoutRoute, | ||||
| 	'home': indexHomeRoute, | ||||
| 	'profile': indexProfileRoute, | ||||
| 	'users': indexUsersRoute, | ||||
| 	'user': indexUserRoute, | ||||
|  | ||||
| 	'': defaultRoute, | ||||
| }; | ||||
|  | ||||
| export default routes; | ||||
							
								
								
									
										103
									
								
								packages/server/src/services/MustacheService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								packages/server/src/services/MustacheService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import * as Mustache from 'mustache'; | ||||
| import * as fs from 'fs-extra'; | ||||
| import config, { baseUrl } from '../config'; | ||||
|  | ||||
| export interface RenderOptions { | ||||
| 	partials?: any; | ||||
| 	cssFiles?: string[]; | ||||
| 	jsFiles?: string[]; | ||||
| } | ||||
|  | ||||
| export interface View { | ||||
| 	name: string; | ||||
| 	path: string; | ||||
| 	content?: any; | ||||
| 	partials?: string[]; | ||||
| 	cssFiles?: string[]; | ||||
| 	jsFiles?: string[]; | ||||
| } | ||||
|  | ||||
| export function isView(o: any): boolean { | ||||
| 	if (typeof o !== 'object' || !o) return false; | ||||
| 	return 'path' in o && 'name' in o; | ||||
| } | ||||
|  | ||||
| class MustacheService { | ||||
|  | ||||
| 	private get defaultLayoutPath(): string { | ||||
| 		return `${config().layoutDir}/default.mustache`; | ||||
| 	} | ||||
|  | ||||
| 	private get defaultLayoutOptions(): any { | ||||
| 		return { | ||||
| 			baseUrl: baseUrl(), | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	private async loadTemplateContent(path: string): Promise<string> { | ||||
| 		return fs.readFile(path, 'utf8'); | ||||
| 	} | ||||
|  | ||||
| 	private resolvesFilePaths(type: string, paths: string[]): string[] { | ||||
| 		const output: string[] = []; | ||||
| 		for (const path of paths) { | ||||
| 			output.push(`${baseUrl()}/${type}/${path}.${type}`); | ||||
| 		} | ||||
| 		return output; | ||||
| 	} | ||||
|  | ||||
| 	public async renderView(view: View): Promise<string> { | ||||
| 		const partials = view.partials || []; | ||||
| 		const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []); | ||||
| 		const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []); | ||||
|  | ||||
| 		const partialContents: any = {}; | ||||
| 		for (const partialName of partials) { | ||||
| 			const filePath = `${config().viewDir}/partials/${partialName}.mustache`; | ||||
| 			partialContents[partialName] = await this.loadTemplateContent(filePath); | ||||
| 		} | ||||
|  | ||||
| 		const filePath = `${config().viewDir}/${view.path}.mustache`; | ||||
|  | ||||
| 		const contentHtml = Mustache.render( | ||||
| 			await this.loadTemplateContent(filePath), | ||||
| 			{ | ||||
| 				...view.content, | ||||
| 				global: this.defaultLayoutOptions, | ||||
| 			}, | ||||
| 			partialContents | ||||
| 		); | ||||
|  | ||||
| 		const layoutView: any = Object.assign({}, this.defaultLayoutOptions, { | ||||
| 			pageName: view.name, | ||||
| 			contentHtml: contentHtml, | ||||
| 			cssFiles: cssFiles, | ||||
| 			jsFiles: jsFiles, | ||||
| 			...view.content, | ||||
| 		}); | ||||
|  | ||||
| 		return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents); | ||||
| 	} | ||||
|  | ||||
| 	// public async render(path: string, view: any, options: RenderOptions = null): Promise<string> { | ||||
| 	// 	const partials = options && options.partials ? options.partials : {}; | ||||
| 	// 	const cssFiles = this.resolvesFilePaths('css', options && options.cssFiles ? options.cssFiles : []); | ||||
| 	// 	const jsFiles = this.resolvesFilePaths('js', options && options.jsFiles ? options.jsFiles : []); | ||||
|  | ||||
| 	// 	const filePath = `${config().viewDir}/${path}.mustache`; | ||||
| 	// 	const contentHtml = Mustache.render(await this.loadTemplateContent(filePath), { ...view, global: this.defaultLayoutOptions }, partials); | ||||
|  | ||||
| 	// 	const layoutView: any = Object.assign({}, this.defaultLayoutOptions, { | ||||
| 	// 		contentHtml: contentHtml, | ||||
| 	// 		cssFiles: cssFiles, | ||||
| 	// 		jsFiles: jsFiles, | ||||
| 	// 	}); | ||||
|  | ||||
| 	// 	return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView); | ||||
| 	// } | ||||
|  | ||||
| } | ||||
|  | ||||
| const mustacheService = new MustacheService(); | ||||
|  | ||||
| export default mustacheService; | ||||
							
								
								
									
										27
									
								
								packages/server/src/tools/db-migrate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/server/src/tools/db-migrate.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // import db, { dbConfig } from '../app/db'; | ||||
|  | ||||
| // // require('source-map-support').install(); | ||||
|  | ||||
| // const config = { | ||||
| // 	directory: `${__dirname}/../migrations`, | ||||
| // 	// Disable transactions because the models might open one too | ||||
| // 	disableTransactions: true, | ||||
| // }; | ||||
|  | ||||
| // console.info(`Using database: ${dbConfig().connection.filename}`); | ||||
| // console.info(`Running migrations in: ${config.directory}`); | ||||
|  | ||||
| // db().migrate.latest(config).then((event: any) => { | ||||
| // 	const log: string[] = event[1]; | ||||
|  | ||||
| // 	if (!log.length) { | ||||
| // 		console.info('Database is already up to date'); | ||||
| // 	} else { | ||||
| // 		console.info(`Ran migrations: ${log.join(', ')}`); | ||||
| // 	} | ||||
|  | ||||
| // 	db().destroy(); | ||||
| // }).catch((error:any) => { | ||||
| // 	console.error(error); | ||||
| // 	process.exit(1); | ||||
| // }); | ||||
							
								
								
									
										76
									
								
								packages/server/src/tools/dbTools.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/server/src/tools/dbTools.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import { connectDb, disconnectDb, migrateDb, sqliteFilePath } from '../db'; | ||||
| import * as fs from 'fs-extra'; | ||||
| import { DatabaseConfig } from '../utils/types'; | ||||
|  | ||||
| const { execCommand } = require('@joplin/tools/tool-utils'); | ||||
|  | ||||
| export interface CreateDbOptions { | ||||
| 	dropIfExists: boolean; | ||||
| } | ||||
|  | ||||
| export interface DropDbOptions { | ||||
| 	ignoreIfNotExists: boolean; | ||||
| } | ||||
|  | ||||
| export async function createDb(config: DatabaseConfig, options: CreateDbOptions = null) { | ||||
| 	options = { | ||||
| 		dropIfExists: false, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	if (config.client === 'pg') { | ||||
| 		const cmd: string[] = [ | ||||
| 			'createdb', | ||||
| 			'--host', config.host, | ||||
| 			'--port', config.port.toString(), | ||||
| 			'--username', config.user, | ||||
| 			config.name, | ||||
| 		]; | ||||
|  | ||||
| 		if (options.dropIfExists) { | ||||
| 			await dropDb(config, { ignoreIfNotExists: true }); | ||||
| 		} | ||||
|  | ||||
| 		await execCommand(cmd.join(' ')); | ||||
| 	} else if (config.client === 'sqlite3') { | ||||
| 		const filePath = sqliteFilePath(config); | ||||
|  | ||||
| 		if (await fs.pathExists(filePath)) { | ||||
| 			if (options.dropIfExists) { | ||||
| 				await fs.remove(filePath); | ||||
| 			} else { | ||||
| 				throw new Error(`Database already exists: ${filePath}`); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const db = await connectDb(config); | ||||
| 	await migrateDb(db); | ||||
| 	await disconnectDb(db); | ||||
| } | ||||
|  | ||||
| export async function dropDb(config: DatabaseConfig, options: DropDbOptions = null) { | ||||
| 	options = { | ||||
| 		ignoreIfNotExists: false, | ||||
| 		...options, | ||||
| 	}; | ||||
|  | ||||
| 	if (config.client === 'pg') { | ||||
| 		const cmd: string[] = [ | ||||
| 			'dropdb', | ||||
| 			'--host', config.host, | ||||
| 			'--port', config.port.toString(), | ||||
| 			'--username', config.user, | ||||
| 			config.name, | ||||
| 		]; | ||||
|  | ||||
| 		try { | ||||
| 			await execCommand(cmd.join(' ')); | ||||
| 		} catch (error) { | ||||
| 			if (options.ignoreIfNotExists && error.message.includes('does not exist')) return; | ||||
| 			throw error; | ||||
| 		} | ||||
| 	} else if (config.client === 'sqlite3') { | ||||
| 		await fs.remove(sqliteFilePath(config)); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										127
									
								
								packages/server/src/tools/generate-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								packages/server/src/tools/generate-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| import sqlts from '@rmp135/sql-ts'; | ||||
|  | ||||
| require('source-map-support').install(); | ||||
|  | ||||
| const dbFilePath: string = `${__dirname}/../../src/db.ts`; | ||||
|  | ||||
| const fileReplaceWithinMarker = '// AUTO-GENERATED-TYPES'; | ||||
|  | ||||
| const config = { | ||||
| 	'client': 'sqlite3', | ||||
| 	'connection': { | ||||
| 		'filename': './db-buildTypes.sqlite', | ||||
| 	}, | ||||
| 	'useNullAsDefault': true, | ||||
| 	'excludedTables': [ | ||||
| 		'main.knex_migrations', | ||||
| 		'main.knex_migrations_lock', | ||||
| 		'android_metadata', | ||||
| 	], | ||||
| 	'interfaceNameFormat': '${table}', | ||||
| 	'singularTableNames': true, | ||||
| 	'tableNameCasing': 'pascal' as any, | ||||
| 	'filename': './db', | ||||
| 	'extends': { | ||||
| 		'main.sessions': 'WithDates, WithUuid', | ||||
| 		'main.users': 'WithDates, WithUuid', | ||||
| 		'main.permissions': 'WithDates, WithUuid', | ||||
| 		'main.files': 'WithDates, WithUuid', | ||||
| 		'main.api_clients': 'WithDates, WithUuid', | ||||
| 		'main.changes': 'WithDates, WithUuid', | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void { | ||||
| 	const fs = require('fs'); | ||||
| 	if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); | ||||
| 	let content: string = fs.readFileSync(filePath, 'utf-8'); | ||||
| 	// [^]* matches any character including new lines | ||||
| 	const regex: RegExp = new RegExp(`${markerOpen}[^]*?${markerClose}`); | ||||
| 	if (!content.match(regex)) throw new Error(`Could not find markers: ${markerOpen}`); | ||||
| 	content = content.replace(regex, `${markerOpen}\n${contentToInsert}\n${markerClose}`); | ||||
| 	fs.writeFileSync(filePath, content); | ||||
| } | ||||
|  | ||||
| // To output: | ||||
| // | ||||
| // export interface User extends WithDates, WithUuid { | ||||
| // 	email?: string | ||||
| // 	password?: string | ||||
| // 	is_admin?: number | ||||
| // } | ||||
| function createTypeString(table: any) { | ||||
| 	const colStrings = []; | ||||
| 	for (const col of table.columns) { | ||||
| 		const name = col.propertyName as string; | ||||
| 		let type = col.propertyType; | ||||
|  | ||||
| 		if (table.extends && table.extends.indexOf('WithDates') >= 0) { | ||||
| 			if (['created_time', 'updated_time'].includes(name)) continue; | ||||
| 		} | ||||
|  | ||||
| 		if (table.extends && table.extends.indexOf('WithUuid') >= 0) { | ||||
| 			if (['id'].includes(name)) continue; | ||||
| 		} | ||||
|  | ||||
| 		if (name === 'item_type') type = 'ItemType'; | ||||
| 		if (table.name === 'files' && name === 'content') type = 'Buffer'; | ||||
| 		if (table.name === 'changes' && name === 'type') type = 'ChangeType'; | ||||
| 		if ((name === 'id' || name.endsWith('_id') || name === 'uuid') && type === 'string') type = 'Uuid'; | ||||
|  | ||||
| 		colStrings.push(`\t${name}?: ${type};`); | ||||
| 	} | ||||
|  | ||||
| 	const header = ['export interface']; | ||||
| 	header.push(table.interfaceName); | ||||
| 	if (table.extends) header.push(`extends ${table.extends}`); | ||||
|  | ||||
| 	return `${header.join(' ')} {\n${colStrings.join('\n')}\n}`; | ||||
| } | ||||
|  | ||||
| // To output: | ||||
| // | ||||
| // export const databaseSchema:DatabaseTables = { | ||||
| // 	users: { | ||||
| // 		id: { type: "string" }, | ||||
| // 		email: { type: "string" }, | ||||
| // 		password: { type: "string" }, | ||||
| // 		is_admin: { type: "number" }, | ||||
| // 		updated_time: { type: "number" }, | ||||
| // 		created_time: { type: "number" }, | ||||
| // 	}, | ||||
| // } | ||||
| function createRuntimeObject(table: any) { | ||||
| 	const colStrings = []; | ||||
| 	for (const col of table.columns) { | ||||
| 		const name = col.propertyName; | ||||
| 		const type = col.propertyType; | ||||
| 		colStrings.push(`\t\t${name}: { type: '${type}' },`); | ||||
| 	} | ||||
|  | ||||
| 	return `\t${table.name}: {\n${colStrings.join('\n')}\n\t},`; | ||||
| } | ||||
|  | ||||
| async function main() { | ||||
| 	const definitions = await sqlts.toObject(config); | ||||
|  | ||||
| 	const typeStrings = []; | ||||
| 	for (const table of definitions.tables) { | ||||
| 		typeStrings.push(createTypeString(table)); | ||||
| 	} | ||||
|  | ||||
| 	const tableStrings = []; | ||||
| 	for (const table of definitions.tables) { | ||||
| 		tableStrings.push(createRuntimeObject(table)); | ||||
| 	} | ||||
|  | ||||
| 	let content = `// Auto-generated using \`npm run generate-types\`\n${typeStrings.join('\n\n')}`; | ||||
| 	content += '\n\n'; | ||||
| 	content += `export const databaseSchema: DatabaseTables = {\n${tableStrings.join('\n')}\n};`; | ||||
|  | ||||
| 	insertContentIntoFile(dbFilePath, fileReplaceWithinMarker, fileReplaceWithinMarker, content); | ||||
| } | ||||
|  | ||||
| main().catch(error => { | ||||
| 	console.error('Fatal error', error); | ||||
| 	process.exit(1); | ||||
| }); | ||||
							
								
								
									
										81
									
								
								packages/server/src/utils/TransactionHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								packages/server/src/utils/TransactionHandler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| import * as Knex from 'knex'; | ||||
| import { DbConnection } from '../db'; | ||||
|  | ||||
| // This transaction handler allows abstracting away the complexity of managing nested transactions | ||||
| // within models. | ||||
| // Any method in a model can start a transaction and, if one is already started, it | ||||
| // simply won't do anything. The last active transaction commits the results. If a rollback | ||||
| // happens, the following calls to rollback will be a no-op. | ||||
| // Set logEnabled_ to `true` to see what happens with nested transactions. | ||||
| export default class TransactionHandler { | ||||
|  | ||||
| 	private transactionStack_: number[] = []; | ||||
| 	private activeTransaction_: Knex.Transaction = null; | ||||
| 	private transactionIndex_: number = 0; | ||||
| 	private logEnabled_: boolean = false; | ||||
| 	private db_: Knex = null; | ||||
|  | ||||
| 	public constructor(db: DbConnection) { | ||||
| 		this.db_ = db; | ||||
| 	} | ||||
|  | ||||
| 	private get db(): DbConnection { | ||||
| 		return this.db_; | ||||
| 	} | ||||
|  | ||||
| 	public setDb(db: DbConnection) { | ||||
| 		this.db_ = db; | ||||
| 	} | ||||
|  | ||||
| 	private log(s: string): void { | ||||
| 		if (!this.logEnabled_) return; | ||||
| 		console.info(`TransactionHandler: ${s}`); | ||||
| 	} | ||||
|  | ||||
| 	public get activeTransaction(): Knex.Transaction { | ||||
| 		return this.activeTransaction_; | ||||
| 	} | ||||
|  | ||||
| 	public async start(): Promise<number> { | ||||
| 		const txIndex = ++this.transactionIndex_; | ||||
| 		this.log(`Starting transaction: ${txIndex}`); | ||||
|  | ||||
| 		if (!this.transactionStack_.length) { | ||||
| 			if (this.activeTransaction_) throw new Error('An active transaction was found when no transaction was in stack'); // Sanity check | ||||
| 			this.log(`Trying to acquire transaction: ${txIndex}`); | ||||
| 			this.activeTransaction_ = await this.db.transaction(); | ||||
| 			this.log(`Got transaction: ${txIndex}`); | ||||
| 		} | ||||
|  | ||||
| 		this.transactionStack_.push(txIndex); | ||||
| 		return txIndex; | ||||
| 	} | ||||
|  | ||||
| 	private finishTransaction(txIndex: number): boolean { | ||||
| 		if (!this.transactionStack_.length) throw new Error('Committing but no transaction was started'); | ||||
| 		const lastTxIndex = this.transactionStack_.pop(); | ||||
| 		if (lastTxIndex !== txIndex) throw new Error(`Committing a transaction but was not last to start one: ${txIndex}. Expected: ${lastTxIndex}`); | ||||
| 		return !this.transactionStack_.length; | ||||
| 	} | ||||
|  | ||||
| 	public async commit(txIndex: number): Promise<void> { | ||||
| 		this.log(`Commit transaction: ${txIndex}`); | ||||
| 		const isLastTransaction = this.finishTransaction(txIndex); | ||||
| 		if (isLastTransaction) { | ||||
| 			this.log(`Is last transaction - doing commit: ${txIndex}`); | ||||
| 			await this.activeTransaction_.commit(); | ||||
| 			this.activeTransaction_ = null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async rollback(txIndex: number): Promise<void> { | ||||
| 		this.log(`Rollback transaction: ${txIndex}`); | ||||
| 		this.finishTransaction(txIndex); | ||||
| 		if (this.activeTransaction_) { | ||||
| 			this.log(`Transaction is active - doing rollback: ${txIndex}`); | ||||
| 			await this.activeTransaction_.rollback(); | ||||
| 			this.activeTransaction_ = null; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| } | ||||
							
								
								
									
										10
									
								
								packages/server/src/utils/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								packages/server/src/utils/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| const bcrypt = require('bcryptjs'); | ||||
|  | ||||
| export function hashPassword(password: string): string { | ||||
| 	const salt = bcrypt.genSaltSync(10); | ||||
| 	return bcrypt.hashSync(password, salt); | ||||
| } | ||||
|  | ||||
| export function checkPassword(password: string, hash: string): boolean { | ||||
| 	return bcrypt.compareSync(password, hash); | ||||
| } | ||||
							
								
								
									
										7
									
								
								packages/server/src/utils/base64.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/server/src/utils/base64.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| export function encodeBase64(s: string): string { | ||||
| 	return Buffer.from(s).toString('base64'); | ||||
| } | ||||
|  | ||||
| export function decodeBase64(s: string): string { | ||||
| 	return Buffer.from(s, 'base64').toString('utf8'); | ||||
| } | ||||
							
								
								
									
										53
									
								
								packages/server/src/utils/cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								packages/server/src/utils/cache.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| interface CacheEntry { | ||||
| 	object: any; | ||||
| 	timestamp: number; | ||||
| } | ||||
|  | ||||
| interface CacheEntries { | ||||
| 	[key: string]: CacheEntry; | ||||
| } | ||||
|  | ||||
| class Cache { | ||||
|  | ||||
| 	cache: CacheEntries = {}; | ||||
|  | ||||
| 	private async setAny(key: string, o: any): Promise<void> { | ||||
| 		this.cache[key] = { | ||||
| 			object: JSON.stringify(o), | ||||
| 			timestamp: Date.now(), | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	async setObject(key: string, object: Object): Promise<void> { | ||||
| 		if (!object) return; | ||||
| 		return this.setAny(key, object); | ||||
| 	} | ||||
|  | ||||
| 	private async getAny(key: string): Promise<any> { | ||||
| 		if (!this.cache[key]) return null; | ||||
| 		try { | ||||
| 			const output = JSON.parse(this.cache[key].object); | ||||
| 			return output; | ||||
| 		} catch (error) { | ||||
| 			throw new Error(`Cannot unserialize object: ${key}: ${error.message}: ${this.cache[key].object}`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async object(key: string): Promise<object> { | ||||
| 		return this.getAny(key) as object; | ||||
| 	} | ||||
|  | ||||
| 	async delete(key: string | string[]): Promise<void> { | ||||
| 		const keys = typeof key === 'string' ? [key] : key; | ||||
| 		for (const k of keys) delete this.cache[k]; | ||||
| 	} | ||||
|  | ||||
| 	async clearAll(): Promise<void> { | ||||
| 		this.cache = {}; | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| const cache: Cache = new Cache(); | ||||
|  | ||||
| export default cache; | ||||
							
								
								
									
										14
									
								
								packages/server/src/utils/defaultView.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/server/src/utils/defaultView.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { User } from '../db'; | ||||
| import { View } from '../services/MustacheService'; | ||||
|  | ||||
| // Populate a View object with some good defaults. | ||||
| export default function(name: string, owner: User = null): View { | ||||
| 	return { | ||||
| 		name: name, | ||||
| 		path: `index/${name}`, | ||||
| 		content: { | ||||
| 			owner, | ||||
| 		}, | ||||
| 		partials: ['navbar'], | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										63
									
								
								packages/server/src/utils/errors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								packages/server/src/utils/errors.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| // For explanation of the setPrototypeOf call, see: | ||||
| // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work | ||||
|  | ||||
| class ApiError extends Error { | ||||
| 	public httpCode: number; | ||||
| 	public code: string; | ||||
| 	public constructor(message: string, httpCode: number = 400, code: string = undefined) { | ||||
| 		super(message); | ||||
| 		this.httpCode = httpCode; | ||||
| 		this.code = code; | ||||
| 		Object.setPrototypeOf(this, ApiError.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorMethodNotAllowed extends ApiError { | ||||
| 	public constructor(message: string = 'Method Not Allowed') { | ||||
| 		super(message, 405); | ||||
| 		Object.setPrototypeOf(this, ErrorMethodNotAllowed.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorNotFound extends ApiError { | ||||
| 	public constructor(message: string = 'Not Found') { | ||||
| 		super(message, 404); | ||||
| 		Object.setPrototypeOf(this, ErrorNotFound.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorForbidden extends ApiError { | ||||
| 	public constructor(message: string = 'Forbidden') { | ||||
| 		super(message, 403); | ||||
| 		Object.setPrototypeOf(this, ErrorForbidden.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorBadRequest extends ApiError { | ||||
| 	public constructor(message: string = 'Bad Request') { | ||||
| 		super(message, 400); | ||||
| 		Object.setPrototypeOf(this, ErrorBadRequest.prototype); | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| export class ErrorUnprocessableEntity extends ApiError { | ||||
| 	public constructor(message: string = 'Unprocessable Entity') { | ||||
| 		super(message, 422); | ||||
| 		Object.setPrototypeOf(this, ErrorUnprocessableEntity.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorConflict extends ApiError { | ||||
| 	public constructor(message: string = 'Conflict') { | ||||
| 		super(message, 409); | ||||
| 		Object.setPrototypeOf(this, ErrorConflict.prototype); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class ErrorResyncRequired extends ApiError { | ||||
| 	public constructor(message: string = 'Delta cursor is invalid and the complete data should be resynced') { | ||||
| 		super(message, 400, 'resyncRequired'); | ||||
| 		Object.setPrototypeOf(this, ErrorResyncRequired.prototype); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										8
									
								
								packages/server/src/utils/htmlUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								packages/server/src/utils/htmlUtils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
|  | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
|  | ||||
| export function escapeHtml(s: string) { | ||||
| 	return htmlentities(s); | ||||
| } | ||||
							
								
								
									
										13
									
								
								packages/server/src/utils/koaIf.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								packages/server/src/utils/koaIf.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { Context } from 'koa'; | ||||
|  | ||||
| export default function koaIf(middleware: Function, condition: any = null) { | ||||
| 	return async (ctx: Context, next: Function) => { | ||||
| 		if (typeof condition === 'function' && condition(ctx)) { | ||||
| 			await middleware(ctx, next); | ||||
| 		} else if (typeof condition === 'boolean' && condition) { | ||||
| 			await middleware(ctx, next); | ||||
| 		} else { | ||||
| 			await next(); | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										45
									
								
								packages/server/src/utils/requestUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								packages/server/src/utils/requestUtils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { ErrorBadRequest, ErrorForbidden } from './errors'; | ||||
| import { AppContext } from './types'; | ||||
|  | ||||
| const formidable = require('formidable'); | ||||
|  | ||||
| export type BodyFields = Record<string, any>; | ||||
|  | ||||
| interface FormParseResult { | ||||
| 	fields: BodyFields; | ||||
| 	files: any; | ||||
| } | ||||
|  | ||||
| // Input should be Koa ctx.req, which corresponds to the native Node request | ||||
| export async function formParse(req: any): Promise<FormParseResult> { | ||||
| 	return new Promise((resolve: Function, reject: Function) => { | ||||
| 		const form = formidable({ multiples: true }); | ||||
| 		form.parse(req, (error: any, fields: any, files: any) => { | ||||
| 			if (error) { | ||||
| 				reject(error); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			resolve({ fields, files }); | ||||
| 		}); | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export async function bodyFields(req: any): Promise<BodyFields> { | ||||
| 	if (req.headers['content-type'] !== 'application/json') { | ||||
| 		throw new ErrorBadRequest(`Unsupported Content-Type: "${req.headers['content-type']}". Expected: "application/json"`); | ||||
| 	} | ||||
|  | ||||
| 	const form = await formParse(req); | ||||
| 	return form.fields; | ||||
| } | ||||
|  | ||||
| export function headerSessionId(headers: any): string { | ||||
| 	return headers['x-api-auth'] ? headers['x-api-auth'] : ''; | ||||
| } | ||||
|  | ||||
| export function contextSessionId(ctx: AppContext): string { | ||||
| 	const id = ctx.cookies.get('sessionId'); | ||||
| 	if (!id) throw new ErrorForbidden('Invalid or missing session'); | ||||
| 	return id; | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user