You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
Server: Support for notifications and clean up
This commit is contained in:
@ -1460,6 +1460,9 @@ packages/server/src/controllers/index/HomeController.js.map
|
|||||||
packages/server/src/controllers/index/LoginController.d.ts
|
packages/server/src/controllers/index/LoginController.d.ts
|
||||||
packages/server/src/controllers/index/LoginController.js
|
packages/server/src/controllers/index/LoginController.js
|
||||||
packages/server/src/controllers/index/LoginController.js.map
|
packages/server/src/controllers/index/LoginController.js.map
|
||||||
|
packages/server/src/controllers/index/NotificationController.d.ts
|
||||||
|
packages/server/src/controllers/index/NotificationController.js
|
||||||
|
packages/server/src/controllers/index/NotificationController.js.map
|
||||||
packages/server/src/controllers/index/ProfileController.d.ts
|
packages/server/src/controllers/index/ProfileController.d.ts
|
||||||
packages/server/src/controllers/index/ProfileController.js
|
packages/server/src/controllers/index/ProfileController.js
|
||||||
packages/server/src/controllers/index/ProfileController.js.map
|
packages/server/src/controllers/index/ProfileController.js.map
|
||||||
@ -1469,9 +1472,18 @@ packages/server/src/controllers/index/UserController.js.map
|
|||||||
packages/server/src/db.d.ts
|
packages/server/src/db.d.ts
|
||||||
packages/server/src/db.js
|
packages/server/src/db.js
|
||||||
packages/server/src/db.js.map
|
packages/server/src/db.js.map
|
||||||
|
packages/server/src/middleware/notificationHandler.d.ts
|
||||||
|
packages/server/src/middleware/notificationHandler.js
|
||||||
|
packages/server/src/middleware/notificationHandler.js.map
|
||||||
|
packages/server/src/middleware/requestProcessor.d.ts
|
||||||
|
packages/server/src/middleware/requestProcessor.js
|
||||||
|
packages/server/src/middleware/requestProcessor.js.map
|
||||||
packages/server/src/migrations/20190913171451_create.d.ts
|
packages/server/src/migrations/20190913171451_create.d.ts
|
||||||
packages/server/src/migrations/20190913171451_create.js
|
packages/server/src/migrations/20190913171451_create.js
|
||||||
packages/server/src/migrations/20190913171451_create.js.map
|
packages/server/src/migrations/20190913171451_create.js.map
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.d.ts
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.js
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.js.map
|
||||||
packages/server/src/models/ApiClientModel.d.ts
|
packages/server/src/models/ApiClientModel.d.ts
|
||||||
packages/server/src/models/ApiClientModel.js
|
packages/server/src/models/ApiClientModel.js
|
||||||
packages/server/src/models/ApiClientModel.js.map
|
packages/server/src/models/ApiClientModel.js.map
|
||||||
@ -1490,6 +1502,12 @@ packages/server/src/models/FileModel.js.map
|
|||||||
packages/server/src/models/FileModel.test.d.ts
|
packages/server/src/models/FileModel.test.d.ts
|
||||||
packages/server/src/models/FileModel.test.js
|
packages/server/src/models/FileModel.test.js
|
||||||
packages/server/src/models/FileModel.test.js.map
|
packages/server/src/models/FileModel.test.js.map
|
||||||
|
packages/server/src/models/NotificationModel.d.ts
|
||||||
|
packages/server/src/models/NotificationModel.js
|
||||||
|
packages/server/src/models/NotificationModel.js.map
|
||||||
|
packages/server/src/models/NotificationModel.test.d.ts
|
||||||
|
packages/server/src/models/NotificationModel.test.js
|
||||||
|
packages/server/src/models/NotificationModel.test.js.map
|
||||||
packages/server/src/models/PermissionModel.d.ts
|
packages/server/src/models/PermissionModel.d.ts
|
||||||
packages/server/src/models/PermissionModel.js
|
packages/server/src/models/PermissionModel.js
|
||||||
packages/server/src/models/PermissionModel.js.map
|
packages/server/src/models/PermissionModel.js.map
|
||||||
@ -1535,6 +1553,9 @@ packages/server/src/routes/index/login.js.map
|
|||||||
packages/server/src/routes/index/logout.d.ts
|
packages/server/src/routes/index/logout.d.ts
|
||||||
packages/server/src/routes/index/logout.js
|
packages/server/src/routes/index/logout.js
|
||||||
packages/server/src/routes/index/logout.js.map
|
packages/server/src/routes/index/logout.js.map
|
||||||
|
packages/server/src/routes/index/notifications.d.ts
|
||||||
|
packages/server/src/routes/index/notifications.js
|
||||||
|
packages/server/src/routes/index/notifications.js.map
|
||||||
packages/server/src/routes/index/profile.d.ts
|
packages/server/src/routes/index/profile.d.ts
|
||||||
packages/server/src/routes/index/profile.js
|
packages/server/src/routes/index/profile.js
|
||||||
packages/server/src/routes/index/profile.js.map
|
packages/server/src/routes/index/profile.js.map
|
||||||
|
21
.gitignore
vendored
21
.gitignore
vendored
@ -1449,6 +1449,9 @@ packages/server/src/controllers/index/HomeController.js.map
|
|||||||
packages/server/src/controllers/index/LoginController.d.ts
|
packages/server/src/controllers/index/LoginController.d.ts
|
||||||
packages/server/src/controllers/index/LoginController.js
|
packages/server/src/controllers/index/LoginController.js
|
||||||
packages/server/src/controllers/index/LoginController.js.map
|
packages/server/src/controllers/index/LoginController.js.map
|
||||||
|
packages/server/src/controllers/index/NotificationController.d.ts
|
||||||
|
packages/server/src/controllers/index/NotificationController.js
|
||||||
|
packages/server/src/controllers/index/NotificationController.js.map
|
||||||
packages/server/src/controllers/index/ProfileController.d.ts
|
packages/server/src/controllers/index/ProfileController.d.ts
|
||||||
packages/server/src/controllers/index/ProfileController.js
|
packages/server/src/controllers/index/ProfileController.js
|
||||||
packages/server/src/controllers/index/ProfileController.js.map
|
packages/server/src/controllers/index/ProfileController.js.map
|
||||||
@ -1458,9 +1461,18 @@ packages/server/src/controllers/index/UserController.js.map
|
|||||||
packages/server/src/db.d.ts
|
packages/server/src/db.d.ts
|
||||||
packages/server/src/db.js
|
packages/server/src/db.js
|
||||||
packages/server/src/db.js.map
|
packages/server/src/db.js.map
|
||||||
|
packages/server/src/middleware/notificationHandler.d.ts
|
||||||
|
packages/server/src/middleware/notificationHandler.js
|
||||||
|
packages/server/src/middleware/notificationHandler.js.map
|
||||||
|
packages/server/src/middleware/requestProcessor.d.ts
|
||||||
|
packages/server/src/middleware/requestProcessor.js
|
||||||
|
packages/server/src/middleware/requestProcessor.js.map
|
||||||
packages/server/src/migrations/20190913171451_create.d.ts
|
packages/server/src/migrations/20190913171451_create.d.ts
|
||||||
packages/server/src/migrations/20190913171451_create.js
|
packages/server/src/migrations/20190913171451_create.js
|
||||||
packages/server/src/migrations/20190913171451_create.js.map
|
packages/server/src/migrations/20190913171451_create.js.map
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.d.ts
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.js
|
||||||
|
packages/server/src/migrations/20203012152842_notifications.js.map
|
||||||
packages/server/src/models/ApiClientModel.d.ts
|
packages/server/src/models/ApiClientModel.d.ts
|
||||||
packages/server/src/models/ApiClientModel.js
|
packages/server/src/models/ApiClientModel.js
|
||||||
packages/server/src/models/ApiClientModel.js.map
|
packages/server/src/models/ApiClientModel.js.map
|
||||||
@ -1479,6 +1491,12 @@ packages/server/src/models/FileModel.js.map
|
|||||||
packages/server/src/models/FileModel.test.d.ts
|
packages/server/src/models/FileModel.test.d.ts
|
||||||
packages/server/src/models/FileModel.test.js
|
packages/server/src/models/FileModel.test.js
|
||||||
packages/server/src/models/FileModel.test.js.map
|
packages/server/src/models/FileModel.test.js.map
|
||||||
|
packages/server/src/models/NotificationModel.d.ts
|
||||||
|
packages/server/src/models/NotificationModel.js
|
||||||
|
packages/server/src/models/NotificationModel.js.map
|
||||||
|
packages/server/src/models/NotificationModel.test.d.ts
|
||||||
|
packages/server/src/models/NotificationModel.test.js
|
||||||
|
packages/server/src/models/NotificationModel.test.js.map
|
||||||
packages/server/src/models/PermissionModel.d.ts
|
packages/server/src/models/PermissionModel.d.ts
|
||||||
packages/server/src/models/PermissionModel.js
|
packages/server/src/models/PermissionModel.js
|
||||||
packages/server/src/models/PermissionModel.js.map
|
packages/server/src/models/PermissionModel.js.map
|
||||||
@ -1524,6 +1542,9 @@ packages/server/src/routes/index/login.js.map
|
|||||||
packages/server/src/routes/index/logout.d.ts
|
packages/server/src/routes/index/logout.d.ts
|
||||||
packages/server/src/routes/index/logout.js
|
packages/server/src/routes/index/logout.js
|
||||||
packages/server/src/routes/index/logout.js.map
|
packages/server/src/routes/index/logout.js.map
|
||||||
|
packages/server/src/routes/index/notifications.d.ts
|
||||||
|
packages/server/src/routes/index/notifications.js
|
||||||
|
packages/server/src/routes/index/notifications.js.map
|
||||||
packages/server/src/routes/index/profile.d.ts
|
packages/server/src/routes/index/profile.d.ts
|
||||||
packages/server/src/routes/index/profile.js
|
packages/server/src/routes/index/profile.js
|
||||||
packages/server/src/routes/index/profile.js.map
|
packages/server/src/routes/index/profile.js.map
|
||||||
|
@ -313,6 +313,17 @@ class Resource extends BaseItem {
|
|||||||
return r ? r.total : 0;
|
return r ? r.total : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async createdLocallyCount() {
|
||||||
|
const r = await this.db().selectOne(`
|
||||||
|
SELECT count(*) as total
|
||||||
|
FROM resources
|
||||||
|
WHERE id NOT IN
|
||||||
|
(SELECT resource_id FROM resource_local_states)
|
||||||
|
`);
|
||||||
|
|
||||||
|
return r ? r.total : 0;
|
||||||
|
}
|
||||||
|
|
||||||
static fetchStatusToLabel(status) {
|
static fetchStatusToLabel(status) {
|
||||||
if (status === Resource.FETCH_STATUS_IDLE) return _('Not downloaded');
|
if (status === Resource.FETCH_STATUS_IDLE) return _('Not downloaded');
|
||||||
if (status === Resource.FETCH_STATUS_STARTED) return _('Downloading');
|
if (status === Resource.FETCH_STATUS_STARTED) return _('Downloading');
|
||||||
|
@ -187,8 +187,10 @@ class ReportService {
|
|||||||
if (status === Resource.FETCH_STATUS_DONE) {
|
if (status === Resource.FETCH_STATUS_DONE) {
|
||||||
const downloadedButEncryptedBlobCount = await Resource.downloadedButEncryptedBlobCount();
|
const downloadedButEncryptedBlobCount = await Resource.downloadedButEncryptedBlobCount();
|
||||||
const downloadedCount = await Resource.downloadStatusCounts(Resource.FETCH_STATUS_DONE);
|
const downloadedCount = await Resource.downloadStatusCounts(Resource.FETCH_STATUS_DONE);
|
||||||
|
const createdLocallyCount = await Resource.createdLocallyCount();
|
||||||
section.body.push(_('%s: %d', _('Downloaded and decrypted'), downloadedCount - downloadedButEncryptedBlobCount));
|
section.body.push(_('%s: %d', _('Downloaded and decrypted'), downloadedCount - downloadedButEncryptedBlobCount));
|
||||||
section.body.push(_('%s: %d', _('Downloaded and encrypted'), downloadedButEncryptedBlobCount));
|
section.body.push(_('%s: %d', _('Downloaded and encrypted'), downloadedButEncryptedBlobCount));
|
||||||
|
section.body.push(_('%s: %d', _('Created locally'), createdLocallyCount));
|
||||||
} else {
|
} else {
|
||||||
const count = await Resource.downloadStatusCounts(status);
|
const count = await Resource.downloadStatusCounts(status);
|
||||||
section.body.push(_('%s: %d', Resource.fetchStatusToLabel(status), count));
|
section.body.push(_('%s: %d', Resource.fetchStatusToLabel(status), count));
|
||||||
|
76
packages/server/package-lock.json
generated
76
packages/server/package-lock.json
generated
@ -1262,6 +1262,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/highlight.js": {
|
||||||
|
"version": "9.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.4.tgz",
|
||||||
|
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/http-assert": {
|
"@types/http-assert": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz",
|
||||||
@ -1331,6 +1337,29 @@
|
|||||||
"@types/koa": "*"
|
"@types/koa": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/linkify-it": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-x9OaQQTb1N2hPZ/LWJsqushexDvz7NgzuZxiRmZio44WPuolTZNHDBCrOxCzRVOMwamJRO2dWax5NbygOf1OTQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/markdown-it": {
|
||||||
|
"version": "12.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.0.0.tgz",
|
||||||
|
"integrity": "sha512-+RJNprPSIcEUBzj3nx8WYwRsDdAKF6/dG932OleYKbTqBSJ7VvZK0JbPKeEpIYxoniUhgvgyZjO4vlCd4mFTdw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/highlight.js": "^9.7.0",
|
||||||
|
"@types/linkify-it": "*",
|
||||||
|
"@types/mdurl": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/mdurl": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/mime": {
|
"@types/mime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
|
||||||
@ -2652,6 +2681,11 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
|
||||||
|
},
|
||||||
"error-ex": {
|
"error-ex": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||||
@ -5562,6 +5596,14 @@
|
|||||||
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
|
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"linkify-it": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==",
|
||||||
|
"requires": {
|
||||||
|
"uc.micro": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"locate-path": {
|
"locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
@ -5632,6 +5674,30 @@
|
|||||||
"object-visit": "^1.0.0"
|
"object-visit": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"markdown-it": {
|
||||||
|
"version": "12.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.0.4.tgz",
|
||||||
|
"integrity": "sha512-34RwOXZT8kyuOJy25oJNJoulO8L0bTHYWXcdZBYZqFnjIy3NgjeoM3FmPXIOFQ26/lSHYMr8oc62B6adxXcb3Q==",
|
||||||
|
"requires": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "~2.1.0",
|
||||||
|
"linkify-it": "^3.0.1",
|
||||||
|
"mdurl": "^1.0.1",
|
||||||
|
"uc.micro": "^1.0.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mdurl": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
|
||||||
|
},
|
||||||
"media-typer": {
|
"media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@ -7177,11 +7243,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sprintf-js": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
|
|
||||||
},
|
|
||||||
"sqlite3": {
|
"sqlite3": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.1.0.tgz",
|
||||||
@ -7583,6 +7644,11 @@
|
|||||||
"integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
|
"integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"uc.micro": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
|
||||||
|
},
|
||||||
"uglify-js": {
|
"uglify-js": {
|
||||||
"version": "3.12.2",
|
"version": "3.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.2.tgz",
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"html-entities": "^1.3.1",
|
"html-entities": "^1.3.1",
|
||||||
"knex": "^0.19.4",
|
"knex": "^0.19.4",
|
||||||
"koa": "^2.8.1",
|
"koa": "^2.8.1",
|
||||||
|
"markdown-it": "^12.0.4",
|
||||||
"mustache": "^3.1.0",
|
"mustache": "^3.1.0",
|
||||||
"nanoid": "^2.1.1",
|
"nanoid": "^2.1.1",
|
||||||
"nodemon": "^2.0.6",
|
"nodemon": "^2.0.6",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"@types/fs-extra": "^8.0.0",
|
"@types/fs-extra": "^8.0.0",
|
||||||
"@types/jest": "^26.0.15",
|
"@types/jest": "^26.0.15",
|
||||||
"@types/koa": "^2.0.49",
|
"@types/koa": "^2.0.49",
|
||||||
|
"@types/markdown-it": "^12.0.0",
|
||||||
"@types/mustache": "^0.8.32",
|
"@types/mustache": "^0.8.32",
|
||||||
"@types/yargs": "^13.0.2",
|
"@types/yargs": "^13.0.2",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
// Allows displaying error stack traces with TypeScript file paths
|
// Allows displaying error stack traces with TypeScript file paths
|
||||||
|
require('source-map-support').install();
|
||||||
|
|
||||||
import * as Koa from 'koa';
|
import * as Koa from 'koa';
|
||||||
import routes from './routes/routes';
|
|
||||||
import { ErrorNotFound } from './utils/errors';
|
|
||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import { argv } from 'yargs';
|
import { argv } from 'yargs';
|
||||||
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from './utils/routeUtils';
|
|
||||||
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
|
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
|
||||||
import config, { initConfig, baseUrl } from './config';
|
import config, { initConfig, baseUrl } from './config';
|
||||||
import configDev from './config-dev';
|
import configDev from './config-dev';
|
||||||
@ -14,9 +13,15 @@ import { createDb, dropDb } from './tools/dbTools';
|
|||||||
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection } from './db';
|
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection } from './db';
|
||||||
import modelFactory from './models/factory';
|
import modelFactory from './models/factory';
|
||||||
import controllerFactory from './controllers/factory';
|
import controllerFactory from './controllers/factory';
|
||||||
import { AppContext, Config } from './utils/types';
|
import { AppContext, Config, Env } from './utils/types';
|
||||||
import FsDriverNode from '@joplin/lib/fs-driver-node';
|
import FsDriverNode from '@joplin/lib/fs-driver-node';
|
||||||
import mustacheService, { isView, View } from './services/MustacheService';
|
import requestProcessor from './middleware/requestProcessor';
|
||||||
|
import notificationHandler from './middleware/notificationHandler';
|
||||||
|
|
||||||
|
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||||
|
shimInit();
|
||||||
|
|
||||||
|
const env: Env = argv.env as Env || Env.Prod;
|
||||||
|
|
||||||
interface Configs {
|
interface Configs {
|
||||||
[name: string]: Config;
|
[name: string]: Config;
|
||||||
@ -28,13 +33,6 @@ const configs: Configs = {
|
|||||||
buildTypes: configBuildTypes,
|
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;
|
let appLogger_: LoggerWrapper = null;
|
||||||
|
|
||||||
function appLogger(): LoggerWrapper {
|
function appLogger(): LoggerWrapper {
|
||||||
@ -46,60 +44,8 @@ function appLogger(): LoggerWrapper {
|
|||||||
|
|
||||||
const app = new Koa();
|
const app = new Koa();
|
||||||
|
|
||||||
app.use(async (ctx: Koa.Context) => {
|
app.use(notificationHandler);
|
||||||
appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
app.use(requestProcessor);
|
||||||
|
|
||||||
const match: MatchedRoute = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const match = findMatchingRoute(ctx.path, routes);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const responseObject = await match.route.exec(match.subPath, ctx);
|
|
||||||
|
|
||||||
if (responseObject instanceof Response) {
|
|
||||||
ctx.response = responseObject.response;
|
|
||||||
} else if (isView(responseObject)) {
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = await mustacheService.renderView(responseObject);
|
|
||||||
} else {
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = responseObject;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new ErrorNotFound();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.httpCode >= 400 && error.httpCode < 500) {
|
|
||||||
appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
|
|
||||||
} else {
|
|
||||||
appLogger().error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
|
||||||
|
|
||||||
const responseFormat = routeResponseFormat(match, ctx.path);
|
|
||||||
|
|
||||||
if (responseFormat === RouteResponseFormat.Html) {
|
|
||||||
ctx.response.set('Content-Type', 'text/html');
|
|
||||||
const view: View = {
|
|
||||||
name: 'error',
|
|
||||||
path: 'index/error',
|
|
||||||
content: {
|
|
||||||
error,
|
|
||||||
stack: env === 'dev' ? error.stack : '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ctx.response.body = await mustacheService.renderView(view);
|
|
||||||
} else { // JSON
|
|
||||||
ctx.response.set('Content-Type', 'application/json');
|
|
||||||
const r: any = { error: error.message };
|
|
||||||
if (env === 'dev' && error.stack) r.stack = error.stack;
|
|
||||||
if (error.code) r.code = error.code;
|
|
||||||
ctx.response.body = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const configObject: Config = configs[env];
|
const configObject: Config = configs[env];
|
||||||
@ -151,9 +97,11 @@ async function main() {
|
|||||||
delete connectionCheckLogInfo.connection;
|
delete connectionCheckLogInfo.connection;
|
||||||
|
|
||||||
appLogger().info('Connection check:', connectionCheckLogInfo);
|
appLogger().info('Connection check:', connectionCheckLogInfo);
|
||||||
|
appContext.env = env;
|
||||||
appContext.db = connectionCheck.connection;
|
appContext.db = connectionCheck.connection;
|
||||||
appContext.models = modelFactory(appContext.db, baseUrl());
|
appContext.models = modelFactory(appContext.db, baseUrl());
|
||||||
appContext.controllers = controllerFactory(appContext.models);
|
appContext.controllers = controllerFactory(appContext.models);
|
||||||
|
appContext.appLogger = appLogger;
|
||||||
|
|
||||||
appLogger().info('Migrating database...');
|
appLogger().info('Migrating database...');
|
||||||
await migrateDb(appContext.db);
|
await migrateDb(appContext.db);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { Session, User } from '../../db';
|
import { Session } from '../../db';
|
||||||
import { checkPassword } from '../../utils/auth';
|
|
||||||
import { ErrorForbidden } from '../../utils/errors';
|
import { ErrorForbidden } from '../../utils/errors';
|
||||||
import uuidgen from '../../utils/uuidgen';
|
import uuidgen from '../../utils/uuidgen';
|
||||||
import BaseController from '../BaseController';
|
import BaseController from '../BaseController';
|
||||||
@ -8,12 +7,10 @@ export default class SessionController extends BaseController {
|
|||||||
|
|
||||||
public async authenticate(email: string, password: string): Promise<Session> {
|
public async authenticate(email: string, password: string): Promise<Session> {
|
||||||
const userModel = this.models.user();
|
const userModel = this.models.user();
|
||||||
const user: User = await userModel.loadByEmail(email);
|
const user = await userModel.login(email, password);
|
||||||
if (!user) throw new ErrorForbidden('Invalid username or password');
|
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 session: Session = { id: uuidgen(), user_id: user.id };
|
||||||
const sessionModel = this.models.session();
|
return this.models.session().save(session, { isNew: true });
|
||||||
return sessionModel.save(session, { isNew: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import IndexHomeController from './index/HomeController';
|
|||||||
import IndexProfileController from './index/ProfileController';
|
import IndexProfileController from './index/ProfileController';
|
||||||
import IndexUserController from './index/UserController';
|
import IndexUserController from './index/UserController';
|
||||||
import IndexFileController from './index/FileController';
|
import IndexFileController from './index/FileController';
|
||||||
|
import IndexNotificationController from './index/NotificationController';
|
||||||
|
|
||||||
export class Controllers {
|
export class Controllers {
|
||||||
|
|
||||||
@ -53,6 +54,10 @@ export class Controllers {
|
|||||||
return new IndexFileController(this.models_);
|
return new IndexFileController(this.models_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public indexNotifications() {
|
||||||
|
return new IndexNotificationController(this.models_);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function(models: Models) {
|
export default function(models: Models) {
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import BaseController from '../BaseController';
|
||||||
|
import { Notification } from '../../db';
|
||||||
|
import { ErrorNotFound } from '../../utils/errors';
|
||||||
|
|
||||||
|
export default class NotificationController extends BaseController {
|
||||||
|
|
||||||
|
public async patchOne(sessionId: string, notificationId: string, notification: Notification): Promise<void> {
|
||||||
|
const owner = await this.initSession(sessionId);
|
||||||
|
const model = this.models.notification({ userId: owner.id });
|
||||||
|
const existingNotification = await model.load(notificationId);
|
||||||
|
if (!existingNotification) throw new ErrorNotFound();
|
||||||
|
|
||||||
|
|
||||||
|
console.info('aaaaaaa', notification);
|
||||||
|
const toSave: Notification = {};
|
||||||
|
if ('read' in notification) toSave.read = notification.read;
|
||||||
|
if (!Object.keys(toSave).length) return;
|
||||||
|
|
||||||
|
toSave.id = notificationId;
|
||||||
|
await model.save(toSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -19,6 +19,9 @@ const logger = Logger.create('db');
|
|||||||
const migrationDir = `${__dirname}/migrations`;
|
const migrationDir = `${__dirname}/migrations`;
|
||||||
const sqliteDbDir = pathUtils.dirname(__dirname);
|
const sqliteDbDir = pathUtils.dirname(__dirname);
|
||||||
|
|
||||||
|
export const defaultAdminEmail = 'admin@localhost';
|
||||||
|
export const defaultAdminPassword = 'admin';
|
||||||
|
|
||||||
export type DbConnection = Knex;
|
export type DbConnection = Knex;
|
||||||
|
|
||||||
export interface DbConfigConnection {
|
export interface DbConfigConnection {
|
||||||
@ -128,6 +131,17 @@ export async function dropTables(db: DbConnection): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function truncateTables(db: DbConnection): Promise<void> {
|
||||||
|
for (const tableName of allTableNames()) {
|
||||||
|
try {
|
||||||
|
await db(tableName).truncate();
|
||||||
|
} catch (error) {
|
||||||
|
if (isNoSuchTableError(error)) continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isNoSuchTableError(error: any): boolean {
|
function isNoSuchTableError(error: any): boolean {
|
||||||
if (error) {
|
if (error) {
|
||||||
// Postgres error: 42P01: undefined_table
|
// Postgres error: 42P01: undefined_table
|
||||||
@ -181,6 +195,11 @@ export enum ItemAddressingType {
|
|||||||
Path,
|
Path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum NotificationLevel {
|
||||||
|
Important = 10,
|
||||||
|
Normal = 20,
|
||||||
|
}
|
||||||
|
|
||||||
export enum ItemType {
|
export enum ItemType {
|
||||||
File = 1,
|
File = 1,
|
||||||
User,
|
User,
|
||||||
@ -261,6 +280,15 @@ export interface ApiClient extends WithDates, WithUuid {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Notification extends WithDates, WithUuid {
|
||||||
|
owner_id?: Uuid;
|
||||||
|
level?: NotificationLevel;
|
||||||
|
key?: string;
|
||||||
|
message?: string;
|
||||||
|
read?: number;
|
||||||
|
canBeDismissed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const databaseSchema: DatabaseTables = {
|
export const databaseSchema: DatabaseTables = {
|
||||||
users: {
|
users: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
@ -320,5 +348,16 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
owner_id: { type: 'string' },
|
||||||
|
level: { type: 'number' },
|
||||||
|
key: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
read: { type: 'number' },
|
||||||
|
canBeDismissed: { type: 'number' },
|
||||||
|
updated_time: { type: 'string' },
|
||||||
|
created_time: { type: 'string' },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
// AUTO-GENERATED-TYPES
|
// AUTO-GENERATED-TYPES
|
||||||
|
52
packages/server/src/middleware/notificationHandler.ts
Normal file
52
packages/server/src/middleware/notificationHandler.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { AppContext, KoaNext, NotificationView } from '../utils/types';
|
||||||
|
import { isApiRequest, contextSessionId } from '../utils/requestUtils';
|
||||||
|
import { defaultAdminEmail, defaultAdminPassword, NotificationLevel, User } from '../db';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import Logger from '@joplin/lib/Logger';
|
||||||
|
import * as MarkdownIt from 'markdown-it';
|
||||||
|
|
||||||
|
const logger = Logger.create('notificationHandler');
|
||||||
|
|
||||||
|
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
|
||||||
|
ctx.notifications = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isApiRequest(ctx)) return next();
|
||||||
|
|
||||||
|
const sessionId = contextSessionId(ctx);
|
||||||
|
const user: User = await ctx.models.session().sessionUser(sessionId);
|
||||||
|
if (!user) return next();
|
||||||
|
|
||||||
|
const notificationModel = ctx.models.notification({ userId: user.id });
|
||||||
|
|
||||||
|
if (user.is_admin) {
|
||||||
|
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
|
||||||
|
|
||||||
|
if (defaultAdmin) {
|
||||||
|
await notificationModel.add(
|
||||||
|
'change_admin_password',
|
||||||
|
NotificationLevel.Important,
|
||||||
|
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownIt = new MarkdownIt();
|
||||||
|
const notifications = await notificationModel.allUnreadByUserId(user.id);
|
||||||
|
const views: NotificationView[] = [];
|
||||||
|
for (const n of notifications) {
|
||||||
|
views.push({
|
||||||
|
id: n.id,
|
||||||
|
messageHtml: markdownIt.render(n.message),
|
||||||
|
level: n.level === NotificationLevel.Important ? 'warning' : 'info',
|
||||||
|
closeUrl: notificationModel.closeUrl(n.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.notifications = views;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
62
packages/server/src/middleware/requestProcessor.ts
Normal file
62
packages/server/src/middleware/requestProcessor.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import routes from '../routes/routes';
|
||||||
|
import { ErrorNotFound } from '../utils/errors';
|
||||||
|
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
|
||||||
|
import { AppContext, Env } from '../utils/types';
|
||||||
|
import mustacheService, { isView, View } from '../services/MustacheService';
|
||||||
|
|
||||||
|
export default async function(ctx: AppContext) {
|
||||||
|
ctx.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, {
|
||||||
|
notifications: ctx.notifications || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = responseObject;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ErrorNotFound();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.httpCode >= 400 && error.httpCode < 500) {
|
||||||
|
ctx.appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
|
||||||
|
} else {
|
||||||
|
ctx.appLogger().error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
||||||
|
|
||||||
|
const responseFormat = routeResponseFormat(match, ctx.path);
|
||||||
|
|
||||||
|
if (responseFormat === RouteResponseFormat.Html) {
|
||||||
|
ctx.response.set('Content-Type', 'text/html');
|
||||||
|
const view: View = {
|
||||||
|
name: 'error',
|
||||||
|
path: 'index/error',
|
||||||
|
content: {
|
||||||
|
error,
|
||||||
|
stack: ctx.env === Env.Dev ? error.stack : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.response.body = await mustacheService.renderView(view);
|
||||||
|
} else { // JSON
|
||||||
|
ctx.response.set('Content-Type', 'application/json');
|
||||||
|
const r: any = { error: error.message };
|
||||||
|
if (ctx.env === Env.Dev && error.stack) r.stack = error.stack;
|
||||||
|
if (error.code) r.code = error.code;
|
||||||
|
ctx.response.body = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import * as Knex from 'knex';
|
import * as Knex from 'knex';
|
||||||
import { DbConnection } from '../db';
|
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
|
||||||
import { hashPassword } from '../utils/auth';
|
import { hashPassword } from '../utils/auth';
|
||||||
import uuidgen from '../utils/uuidgen';
|
import uuidgen from '../utils/uuidgen';
|
||||||
|
|
||||||
@ -97,8 +97,8 @@ export async function up(db: DbConnection): Promise<any> {
|
|||||||
|
|
||||||
await db('users').insert({
|
await db('users').insert({
|
||||||
id: adminId,
|
id: adminId,
|
||||||
email: 'admin@localhost',
|
email: defaultAdminEmail,
|
||||||
password: hashPassword('admin'),
|
password: hashPassword(defaultAdminPassword),
|
||||||
full_name: 'Admin',
|
full_name: 'Admin',
|
||||||
is_admin: 1,
|
is_admin: 1,
|
||||||
updated_time: now,
|
updated_time: now,
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import * as Knex from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.createTable('notifications', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.string('id', 32).unique().primary().notNullable();
|
||||||
|
table.string('owner_id', 32).notNullable();
|
||||||
|
table.integer('level').notNullable();
|
||||||
|
table.text('key', 'string').notNullable();
|
||||||
|
table.text('message', 'mediumtext').notNullable();
|
||||||
|
table.integer('read').defaultTo(0).notNullable();
|
||||||
|
table.integer('canBeDismissed').defaultTo(1).notNullable();
|
||||||
|
table.bigInteger('updated_time').notNullable();
|
||||||
|
table.bigInteger('created_time').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('notifications', function(table: Knex.CreateTableBuilder) {
|
||||||
|
table.unique(['owner_id', 'key']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.dropTable('notifications');
|
||||||
|
}
|
@ -1,11 +1,11 @@
|
|||||||
import { WithDates, WithUuid, File, User, Session, Permission, databaseSchema, ApiClient, DbConnection, Change, ItemType, ChangeType } from '../db';
|
import { WithDates, WithUuid, File, User, Session, Permission, databaseSchema, ApiClient, DbConnection, Change, ItemType, ChangeType, Notification } from '../db';
|
||||||
import TransactionHandler from '../utils/TransactionHandler';
|
import TransactionHandler from '../utils/TransactionHandler';
|
||||||
import uuidgen from '../utils/uuidgen';
|
import uuidgen from '../utils/uuidgen';
|
||||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||||
import { Models } from './factory';
|
import { Models } from './factory';
|
||||||
|
|
||||||
export type AnyItemType = File | User | Session | Permission | ApiClient | Change;
|
export type AnyItemType = File | User | Session | Permission | ApiClient | Change | Notification;
|
||||||
export type AnyItemTypes = File[] | User[] | Session[] | Permission[] | ApiClient[] | Change[];
|
export type AnyItemTypes = File[] | User[] | Session[] | Permission[] | ApiClient[] | Change[] | Notification[];
|
||||||
|
|
||||||
export interface ModelOptions {
|
export interface ModelOptions {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
49
packages/server/src/models/NotificationModel.test.ts
Normal file
49
packages/server/src/models/NotificationModel.test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testUtils';
|
||||||
|
import { Notification, NotificationLevel } from '../db';
|
||||||
|
|
||||||
|
describe('NotificationModel', function() {
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await beforeAllDb('NotificationModel');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await afterAllDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await beforeEachDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should require a user to create the notification', async function() {
|
||||||
|
await expectThrow(async () => models().notification().add('test', NotificationLevel.Normal, 'test'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a notification', async function() {
|
||||||
|
const { user } = await createUserAndSession(1, true);
|
||||||
|
const model = models().notification({ userId: user.id });
|
||||||
|
await model.add('test', NotificationLevel.Important, 'testing');
|
||||||
|
const n: Notification = await model.loadByKey('test');
|
||||||
|
expect(n.key).toBe('test');
|
||||||
|
expect(n.message).toBe('testing');
|
||||||
|
expect(n.level).toBe(NotificationLevel.Important);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create only one notification per key', async function() {
|
||||||
|
const { user } = await createUserAndSession(1, true);
|
||||||
|
const model = models().notification({ userId: user.id });
|
||||||
|
await model.add('test', NotificationLevel.Important, 'testing');
|
||||||
|
await model.add('test', NotificationLevel.Important, 'testing');
|
||||||
|
expect((await model.all()).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mark a notification as read', async function() {
|
||||||
|
const { user } = await createUserAndSession(1, true);
|
||||||
|
const model = models().notification({ userId: user.id });
|
||||||
|
await model.add('test', NotificationLevel.Important, 'testing');
|
||||||
|
expect((await model.loadByKey('test')).read).toBe(0);
|
||||||
|
await model.markAsRead('test');
|
||||||
|
expect((await model.loadByKey('test')).read).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
51
packages/server/src/models/NotificationModel.ts
Normal file
51
packages/server/src/models/NotificationModel.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Notification, NotificationLevel, Uuid } from '../db';
|
||||||
|
import BaseModel from './BaseModel';
|
||||||
|
|
||||||
|
export default class NotificationModel extends BaseModel {
|
||||||
|
|
||||||
|
protected get tableName(): string {
|
||||||
|
return 'notifications';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async add(key: string, level: NotificationLevel, message: string): Promise<Notification> {
|
||||||
|
const n: Notification = await this.loadByKey(key);
|
||||||
|
if (n) return n;
|
||||||
|
return this.save({ key, message, level, owner_id: this.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markAsRead(key: string): Promise<void> {
|
||||||
|
await this.db(this.tableName)
|
||||||
|
.update({ read: 1 })
|
||||||
|
.where('key', '=', key)
|
||||||
|
.andWhere('owner_id', '=', this.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadByKey(key: string): Promise<Notification> {
|
||||||
|
return this.db(this.tableName)
|
||||||
|
.select(this.defaultFields)
|
||||||
|
.where('key', '=', key)
|
||||||
|
.andWhere('owner_id', '=', this.userId)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public allUnreadByUserId(userId: Uuid): Promise<Notification[]> {
|
||||||
|
return this.db(this.tableName)
|
||||||
|
.select(this.defaultFields)
|
||||||
|
.where('owner_id', '=', userId)
|
||||||
|
.andWhere('read', '=', 0)
|
||||||
|
.orderBy('updated_time', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeUrl(id: Uuid): string {
|
||||||
|
return `${this.baseUrl}/notifications/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public load(id: Uuid): Promise<Notification> {
|
||||||
|
return this.db(this.tableName)
|
||||||
|
.select(this.defaultFields)
|
||||||
|
.where({ id: id })
|
||||||
|
.andWhere('owner_id', '=', this.userId)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -14,6 +14,13 @@ export default class UserModel extends BaseModel {
|
|||||||
return this.db<User>(this.tableName).where(user).first();
|
return this.db<User>(this.tableName).where(user).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async login(email: string, password: string): Promise<User> {
|
||||||
|
const user = await this.loadByEmail(email);
|
||||||
|
if (!user) return null;
|
||||||
|
if (!auth.checkPassword(password, user.password)) return null;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
public fromApiInput(object: User): User {
|
public fromApiInput(object: User): User {
|
||||||
const user: User = {};
|
const user: User = {};
|
||||||
|
|
||||||
@ -64,6 +71,10 @@ export default class UserModel extends BaseModel {
|
|||||||
return !!s[0].length && !!s[1].length;
|
return !!s[0].length && !!s[1].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async profileUrl(): Promise<string> {
|
||||||
|
return `${this.baseUrl}/profile`;
|
||||||
|
}
|
||||||
|
|
||||||
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
|
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
|
||||||
if (!this.userId) throw new ErrorForbidden('no user is active');
|
if (!this.userId) throw new ErrorForbidden('no user is active');
|
||||||
|
|
||||||
|
@ -62,6 +62,7 @@ import UserModel from './UserModel';
|
|||||||
import PermissionModel from './PermissionModel';
|
import PermissionModel from './PermissionModel';
|
||||||
import SessionModel from './SessionModel';
|
import SessionModel from './SessionModel';
|
||||||
import ChangeModel from './ChangeModel';
|
import ChangeModel from './ChangeModel';
|
||||||
|
import NotificationModel from './NotificationModel';
|
||||||
|
|
||||||
export class Models {
|
export class Models {
|
||||||
|
|
||||||
@ -96,6 +97,11 @@ export class Models {
|
|||||||
public change(options: ModelOptions = null) {
|
public change(options: ModelOptions = null) {
|
||||||
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_, options);
|
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public notification(options: ModelOptions = null) {
|
||||||
|
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_, options);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
|
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
|
||||||
|
20
packages/server/src/routes/index/notifications.ts
Normal file
20
packages/server/src/routes/index/notifications.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { SubPath, Route } from '../../utils/routeUtils';
|
||||||
|
import { AppContext } from '../../utils/types';
|
||||||
|
import { bodyFields, contextSessionId } from '../../utils/requestUtils';
|
||||||
|
import { ErrorMethodNotAllowed } from '../../utils/errors';
|
||||||
|
|
||||||
|
const route: Route = {
|
||||||
|
|
||||||
|
exec: async function(path: SubPath, ctx: AppContext) {
|
||||||
|
const sessionId = contextSessionId(ctx);
|
||||||
|
|
||||||
|
if (path.id && ctx.method === 'PATCH') {
|
||||||
|
return ctx.controllers.indexNotifications().patchOne(sessionId, path.id, await bodyFields(ctx.req));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ErrorMethodNotAllowed();
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default route;
|
@ -10,6 +10,7 @@ import indexProfileRoute from './index/profile';
|
|||||||
import indexUsersRoute from './index/users';
|
import indexUsersRoute from './index/users';
|
||||||
import indexUserRoute from './index/user';
|
import indexUserRoute from './index/user';
|
||||||
import indexFilesRoute from './index/files';
|
import indexFilesRoute from './index/files';
|
||||||
|
import indexNotificationsRoute from './index/notifications';
|
||||||
import defaultRoute from './default';
|
import defaultRoute from './default';
|
||||||
|
|
||||||
const routes: Routes = {
|
const routes: Routes = {
|
||||||
@ -24,6 +25,7 @@ const routes: Routes = {
|
|||||||
'users': indexUsersRoute,
|
'users': indexUsersRoute,
|
||||||
'user': indexUserRoute,
|
'user': indexUserRoute,
|
||||||
'files': indexFilesRoute,
|
'files': indexFilesRoute,
|
||||||
|
'notifications': indexNotificationsRoute,
|
||||||
|
|
||||||
'': defaultRoute,
|
'': defaultRoute,
|
||||||
};
|
};
|
||||||
|
@ -46,7 +46,7 @@ class MustacheService {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async renderView(view: View): Promise<string> {
|
public async renderView(view: View, globalParams: any = null): Promise<string> {
|
||||||
const partials = view.partials || [];
|
const partials = view.partials || [];
|
||||||
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
|
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
|
||||||
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
|
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
|
||||||
@ -59,17 +59,22 @@ class MustacheService {
|
|||||||
|
|
||||||
const filePath = `${config().viewDir}/${view.path}.mustache`;
|
const filePath = `${config().viewDir}/${view.path}.mustache`;
|
||||||
|
|
||||||
|
globalParams = {
|
||||||
|
...this.defaultLayoutOptions,
|
||||||
|
...globalParams,
|
||||||
|
};
|
||||||
|
|
||||||
const contentHtml = Mustache.render(
|
const contentHtml = Mustache.render(
|
||||||
await this.loadTemplateContent(filePath),
|
await this.loadTemplateContent(filePath),
|
||||||
{
|
{
|
||||||
...view.content,
|
...view.content,
|
||||||
global: this.defaultLayoutOptions,
|
global: globalParams,
|
||||||
},
|
},
|
||||||
partialContents
|
partialContents
|
||||||
);
|
);
|
||||||
|
|
||||||
const layoutView: any = Object.assign({}, {
|
const layoutView: any = Object.assign({}, {
|
||||||
global: this.defaultLayoutOptions,
|
global: globalParams,
|
||||||
pageName: view.name,
|
pageName: view.name,
|
||||||
contentHtml: contentHtml,
|
contentHtml: contentHtml,
|
||||||
cssFiles: cssFiles,
|
cssFiles: cssFiles,
|
||||||
@ -80,23 +85,6 @@ class MustacheService {
|
|||||||
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents);
|
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();
|
const mustacheService = new MustacheService();
|
||||||
|
@ -28,6 +28,7 @@ const config = {
|
|||||||
'main.files': 'WithDates, WithUuid',
|
'main.files': 'WithDates, WithUuid',
|
||||||
'main.api_clients': 'WithDates, WithUuid',
|
'main.api_clients': 'WithDates, WithUuid',
|
||||||
'main.changes': 'WithDates, WithUuid',
|
'main.changes': 'WithDates, WithUuid',
|
||||||
|
'main.notifications': 'WithDates, WithUuid',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,6 +67,7 @@ function createTypeString(table: any) {
|
|||||||
if (name === 'item_type') type = 'ItemType';
|
if (name === 'item_type') type = 'ItemType';
|
||||||
if (table.name === 'files' && name === 'content') type = 'Buffer';
|
if (table.name === 'files' && name === 'content') type = 'Buffer';
|
||||||
if (table.name === 'changes' && name === 'type') type = 'ChangeType';
|
if (table.name === 'changes' && name === 'type') type = 'ChangeType';
|
||||||
|
if (table.name === 'notifications' && name === 'level') type = 'NotificationLevel';
|
||||||
if ((name === 'id' || name.endsWith('_id') || name === 'uuid') && type === 'string') type = 'Uuid';
|
if ((name === 'id' || name.endsWith('_id') || name === 'uuid') && type === 'string') type = 'Uuid';
|
||||||
|
|
||||||
colStrings.push(`\t${name}?: ${type};`);
|
colStrings.push(`\t${name}?: ${type};`);
|
||||||
|
@ -9,6 +9,9 @@ export default function(name: string, owner: User = null): View {
|
|||||||
content: {
|
content: {
|
||||||
owner,
|
owner,
|
||||||
},
|
},
|
||||||
partials: ['navbar'],
|
partials: [
|
||||||
|
'navbar',
|
||||||
|
'notifications',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -43,3 +43,7 @@ export function contextSessionId(ctx: AppContext): string {
|
|||||||
if (!id) throw new ErrorForbidden('Invalid or missing session');
|
if (!id) throw new ErrorForbidden('Invalid or missing session');
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isApiRequest(ctx: AppContext): boolean {
|
||||||
|
return ctx.path.indexOf('/api/') === 0;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { User, Session, DbConnection, connectDb, disconnectDb, File } from '../db';
|
import { User, Session, DbConnection, connectDb, disconnectDb, File, truncateTables } from '../db';
|
||||||
import { createDb } from '../tools/dbTools';
|
import { createDb } from '../tools/dbTools';
|
||||||
import modelFactory from '../models/factory';
|
import modelFactory from '../models/factory';
|
||||||
import controllerFactory from '../controllers/factory';
|
import controllerFactory from '../controllers/factory';
|
||||||
@ -35,17 +35,9 @@ export async function afterAllDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function beforeEachDb() {
|
export async function beforeEachDb() {
|
||||||
await clearDatabase();
|
await truncateTables(db_);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clearDatabase = async function(): Promise<void> {
|
|
||||||
await db_('sessions').truncate();
|
|
||||||
await db_('users').truncate();
|
|
||||||
await db_('permissions').truncate();
|
|
||||||
await db_('files').truncate();
|
|
||||||
await db_('changes').truncate();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const testAssetDir = `${packageRootDir}/assets/tests`;
|
export const testAssetDir = `${packageRootDir}/assets/tests`;
|
||||||
|
|
||||||
interface UserAndSession {
|
interface UserAndSession {
|
||||||
|
@ -1,12 +1,29 @@
|
|||||||
|
import { LoggerWrapper } from '@joplin/lib/Logger';
|
||||||
import * as Koa from 'koa';
|
import * as Koa from 'koa';
|
||||||
import { Controllers } from '../controllers/factory';
|
import { Controllers } from '../controllers/factory';
|
||||||
import { DbConnection } from '../db';
|
import { DbConnection, Uuid } from '../db';
|
||||||
import { Models } from '../models/factory';
|
import { Models } from '../models/factory';
|
||||||
|
|
||||||
|
export enum Env {
|
||||||
|
Dev = 'dev',
|
||||||
|
Prod = 'prod',
|
||||||
|
BuildTypes = 'buildTypes',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationView {
|
||||||
|
id: Uuid;
|
||||||
|
messageHtml: string;
|
||||||
|
level: string;
|
||||||
|
closeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppContext extends Koa.Context {
|
export interface AppContext extends Koa.Context {
|
||||||
|
env: Env;
|
||||||
db: DbConnection;
|
db: DbConnection;
|
||||||
models: Models;
|
models: Models;
|
||||||
controllers: Controllers;
|
controllers: Controllers;
|
||||||
|
appLogger(): LoggerWrapper;
|
||||||
|
notifications: NotificationView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseConfig {
|
export interface DatabaseConfig {
|
||||||
@ -27,3 +44,5 @@ export interface Config {
|
|||||||
logDir: string;
|
logDir: string;
|
||||||
database: DatabaseConfig;
|
database: DatabaseConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KoaNext = ()=> Promise<void>;
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="page-{{{pageName}}}">
|
<body class="page-{{{pageName}}}">
|
||||||
{{> navbar}}
|
{{> navbar}}
|
||||||
|
{{> notifications}}
|
||||||
<main class="main">
|
<main class="main">
|
||||||
{{{contentHtml}}}
|
{{{contentHtml}}}
|
||||||
</main>
|
</main>
|
||||||
|
28
packages/server/src/views/partials/notifications.mustache
Normal file
28
packages/server/src/views/partials/notifications.mustache
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{{#global.notifications}}
|
||||||
|
<div class="notification is-{{level}}" id="notification-{{id}}">
|
||||||
|
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
|
||||||
|
{{{messageHtml}}}
|
||||||
|
</div>
|
||||||
|
{{/global.notifications}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
onDocumentReady(function() {
|
||||||
|
const buttons = document.getElementsByClassName('close-notification-button');
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.addEventListener('click', function(event) {
|
||||||
|
const closeUrl = button.dataset.closeUrl;
|
||||||
|
const notificationId = button.dataset.id;
|
||||||
|
fetch(closeUrl, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
read: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('notification-' + notificationId).style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
Reference in New Issue
Block a user