').addClass(this.$element.prop("checked")?this._onstyle:this._offstyle+" off").addClass(b).addClass(this.options.style);this.$element.wrap(g),a.extend(this,{$toggle:this.$element.parent(),$toggleOn:c,$toggleOff:d,$toggleGroup:f}),this.$toggle.append(f);var h=this.options.width||Math.max(c.outerWidth(),d.outerWidth())+e.outerWidth()/2,i=this.options.height||Math.max(c.outerHeight(),d.outerHeight());c.addClass("toggle-on"),d.addClass("toggle-off"),this.$toggle.css({width:h,height:i}),this.options.height&&(c.css("line-height",c.height()+"px"),d.css("line-height",d.height()+"px")),this.update(!0),this.trigger(!0)},c.prototype.toggle=function(){this.$element.prop("checked")?this.off():this.on()},c.prototype.on=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._offstyle+" off").addClass(this._onstyle),this.$element.prop("checked",!0),void(a||this.trigger()))},c.prototype.off=function(a){return this.$element.prop("disabled")?!1:(this.$toggle.removeClass(this._onstyle).addClass(this._offstyle+" off"),this.$element.prop("checked",!1),void(a||this.trigger()))},c.prototype.enable=function(){this.$toggle.removeAttr("disabled"),this.$element.prop("disabled",!1)},c.prototype.disable=function(){this.$toggle.attr("disabled","disabled"),this.$element.prop("disabled",!0)},c.prototype.update=function(a){this.$element.prop("disabled")?this.disable():this.enable(),this.$element.prop("checked")?this.on(a):this.off(a)},c.prototype.trigger=function(b){this.$element.off("change.bs.toggle"),b||this.$element.change(),this.$element.on("change.bs.toggle",a.proxy(function(){this.update()},this))},c.prototype.destroy=function(){this.$element.off("change.bs.toggle"),this.$toggleGroup.remove(),this.$element.removeData("bs.toggle"),this.$element.unwrap()};var d=a.fn.bootstrapToggle;a.fn.bootstrapToggle=b,a.fn.bootstrapToggle.Constructor=c,a.fn.toggle.noConflict=function(){return a.fn.bootstrapToggle=d,this},a(function(){a("input[type=checkbox][data-toggle^=toggle]").bootstrapToggle()}),a(document).on("click.bs.toggle","div[data-toggle^=toggle]",function(b){var c=a(this).find("input[type=checkbox]");c.bootstrapToggle("toggle"),b.preventDefault()})}(jQuery);
+//# sourceMappingURL=bootstrap-toggle.min.js.map
\ No newline at end of file
diff --git a/app/controllers/api/v1/Profile.controller.js b/app/controllers/api/v1/Profile.controller.js
index 0b2e4f1..565fb6c 100644
--- a/app/controllers/api/v1/Profile.controller.js
+++ b/app/controllers/api/v1/Profile.controller.js
@@ -26,9 +26,101 @@ class ProfileController extends Controller {
uid: user.uid,
tagline: user.tagline,
user_id: user.id,
+ ...(user.notify_config ? { notify_config: await user.notify_config.to_api() } : {})
})
}
+ async fetch_notify(req, res, next) {
+ const User = this.models.get('auth:User')
+
+ let user
+ if ( req.params.user_id === 'me' ) user = req.user
+ else { // If not me, verify that user can modify profile
+ if ( !req.user.can(`profile:update:${req.params.user_id}`) )
+ return res.status(401).api()
+
+ user = await User.findById(req.params.user_id)
+ }
+
+ if ( !user )
+ return res.status(404)
+ .message(req.T('api.user_not_found'))
+ .api()
+
+ if ( user.notify_config ) {
+ return res.api({
+ has_config: true,
+ config: await user.notify_config.to_api(),
+ })
+ } else {
+ return res.api({
+ has_config: false,
+ })
+ }
+ }
+
+ async test_notify(req, res, next) {
+ const User = this.models.get('auth:User')
+
+ let user
+ if ( req.params.user_id === 'me' ) user = req.user
+ else { // If not me, verify that user can modify profile
+ if ( !req.user.can(`profile:update:${req.params.user_id}`) )
+ return res.status(401).api()
+
+ user = await User.findById(req.params.user_id)
+ }
+
+ if ( !user )
+ return res.status(404)
+ .message(req.T('api.user_not_found'))
+ .api()
+
+ if ( user.notify_config && user.notify_config.active ) {
+ await user.notify_config.log({
+ message: req.T('profile.test_notification'),
+ })
+ }
+
+ return res.api()
+ }
+
+ async update_notify(req, res, next) {
+ const User = this.models.get('auth:User')
+ const NotifyConfig = this.models.get('system:NotifyConfig')
+
+ let user
+ if ( req.params.user_id === 'me' ) user = req.user
+ else { // If not me, verify that user can modify profile
+ if ( !req.user.can(`profile:update:${req.params.user_id}`) )
+ return res.status(401).api()
+
+ user = await User.findById(req.params.user_id)
+ }
+
+ if ( !user )
+ return res.status(404)
+ .message(req.T('api.user_not_found'))
+ .api()
+
+ if ( !user.notify_config ) {
+ user.notify_config = new NotifyConfig({
+ user_id: user.id,
+ gateway_url: String(req.body.gateway_url),
+ application_key: String(req.body.app_key),
+ created_on: new Date,
+ active: String(req.body.app_key) && String(req.body.gateway_url),
+ }, user)
+ } else {
+ user.notify_config.application_key = String(req.body.app_key)
+ user.notify_config.gateway_url = String(req.body.gateway_url)
+ user.notify_config.active = String(req.body.app_key) && String(req.body.gateway_url)
+ }
+
+ await user.save()
+ return res.api()
+ }
+
async update(req, res, next) {
const User = this.models.get('auth:User')
diff --git a/app/jobs/ForeignIPLoginAlert.job.js b/app/jobs/ForeignIPLoginAlert.job.js
index 3e7f7a9..4fbae5f 100644
--- a/app/jobs/ForeignIPLoginAlert.job.js
+++ b/app/jobs/ForeignIPLoginAlert.job.js
@@ -28,6 +28,13 @@ class ForeignIPLoginAlertJob extends Job {
button_link: `${this.configs.get('app.url')}dash/profile`,
}
})
+
+ if ( user.notify_config && user.notify_config.active ) {
+ await user.notify_config.log({
+ title: `${this.configs.get('app.name')}: Sign-In From New IP`,
+ message: `Someone signed into your account (${user.uid}) from the IP address ${ip}. If this was you, no further action is required.`,
+ })
+ }
} catch (e) {
this.output.error(e)
}
diff --git a/app/jobs/PasswordReset.job.js b/app/jobs/PasswordReset.job.js
index 5ea946a..7340e5e 100644
--- a/app/jobs/PasswordReset.job.js
+++ b/app/jobs/PasswordReset.job.js
@@ -33,6 +33,15 @@ class PasswordResetJob extends Job {
button_link: key_action.url(),
}
})
+
+ if ( user.notify_config && user.notify_config.active ) {
+ await user.notify_config.log({
+ title: `${this.configs.get('app.name')}: Password Reset Requested`,
+ message: `A password reset request was logged for your account (${user.uid}). If this was you, please check your e-mail for further instructions.`,
+ priority: 8,
+ })
+ }
+
this.output.success('Password reset logged.')
} catch (e) {
this.output.error(e)
diff --git a/app/jobs/PasswordResetAlert.job.js b/app/jobs/PasswordResetAlert.job.js
index 3f3de82..a4cef44 100644
--- a/app/jobs/PasswordResetAlert.job.js
+++ b/app/jobs/PasswordResetAlert.job.js
@@ -27,6 +27,14 @@ class PasswordResetAlertJob extends Job {
],
},
})
+
+ if ( user.notify_config && user.notify_config.active ) {
+ await user.notify_config.log({
+ title: `${this.configs.get('app.name')}: Password Reset`,
+ message: `The password to your account (${user.uid}) was reset from the IP address ${ip}. If this was not you, please contact your system administrator.`,
+ priority: 8,
+ })
+ }
} catch (e) {
this.output.error(e)
}
diff --git a/app/jobs/PushNotify.job.js b/app/jobs/PushNotify.job.js
new file mode 100644
index 0000000..9f1f2cb
--- /dev/null
+++ b/app/jobs/PushNotify.job.js
@@ -0,0 +1,31 @@
+const { Job } = require('flitter-jobs')
+
+class PushNotifyJob extends Job {
+ static get services() {
+ return [...super.services, 'models', 'jobs', 'output']
+ }
+
+ async execute(job) {
+ try {
+ const User = this.models.get('auth:User')
+
+ const { data } = job
+ let { title = '', message, priority = 5, user_id } = data
+
+ const user = await User.findById(user_id)
+ if ( !user ) throw new Error('Invalid user_id.')
+
+ const notify = user.notify_config
+ if ( !notify || !notify.active ) throw new Error('User does not have notifications configured.')
+
+ this.output.info(`Sending notification to ${user.uid}...`)
+
+ await notify.send({ title, message, priority })
+ } catch (e) {
+ this.output.error(e)
+ }
+ this.output.success(`Notification sent!`)
+ }
+}
+
+module.exports = exports = PushNotifyJob
diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js
index 61b5b09..4f9a43d 100644
--- a/app/models/auth/User.model.js
+++ b/app/models/auth/User.model.js
@@ -7,6 +7,7 @@ const PasswordReset = require('./PasswordReset.model')
const AppAuthorization = require('./AppAuthorization.model')
const AppPassword = require('./AppPassword.model')
const uuid = require('uuid').v4
+const NotifyConfig = require('../system/NotifyConfig.model')
/*
* Auth user model. This inherits fields and methods from the default
@@ -36,6 +37,7 @@ class User extends AuthUser {
create_date: {type: Date, default: () => new Date},
photo_file_id: String,
trap: String,
+ notify_config: NotifyConfig,
}}
}
diff --git a/app/models/system/NotifyConfig.model.js b/app/models/system/NotifyConfig.model.js
new file mode 100644
index 0000000..196a61f
--- /dev/null
+++ b/app/models/system/NotifyConfig.model.js
@@ -0,0 +1,42 @@
+const { Model } = require('flitter-orm')
+
+class NotifyConfigModel extends Model {
+ static get services() {
+ return [...super.services, 'notify', 'configs', 'jobs']
+ }
+
+ static get schema() {
+ return {
+ user_id: String,
+ gateway_url: String,
+ application_key: String,
+ created_on: { type: Date, default: () => new Date },
+ active: { type: Boolean, default: true },
+ }
+ }
+
+ async to_api() {
+ return {
+ user_id: this.user_id,
+ gateway_url: this.gateway_url,
+ application_key: this.application_key,
+ created_on: this.created_on,
+ active: this.active,
+ }
+ }
+
+ async send({ title = '', message, priority = 5 }) {
+ if ( !title ) title = this.configs.get('app.name')
+ this.notify.host = this.gateway_url
+ await this.notify.send_one(this.application_key, title, message, priority)
+ this.notify.host = false
+ }
+
+ async log({ title = '', message, priority = 5 }) {
+ await this.jobs.queue('notifications').add('PushNotify', {
+ title, message, priority, user_id: this.user_id,
+ })
+ }
+}
+
+module.exports = exports = NotifyConfigModel
diff --git a/app/routing/routers/api/v1/profile.routes.js b/app/routing/routers/api/v1/profile.routes.js
index 30eb0b5..52ca47c 100644
--- a/app/routing/routers/api/v1/profile.routes.js
+++ b/app/routing/routers/api/v1/profile.routes.js
@@ -10,6 +10,10 @@ const profile_routes = {
['middleware::api:Permission', { check: 'v1:profile:get' }],
'controller::api:v1:Profile.fetch',
],
+ '/:user_id/notify': [ // user_id | 'me'
+ ['middleware::api:Permission', { check: 'v1:profile:get' }],
+ 'controller::api:v1:Profile.fetch_notify',
+ ],
'/:user_id/photo': [
['middleware::api:Permission', { check: 'v1:profile:photo:get' }],
'controller::api:v1:Profile.get_photo',
@@ -22,6 +26,10 @@ const profile_routes = {
['middleware::upload:UploadFile', { tag: 'v1:profile:photo' }],
'controller::api:v1:Profile.update_photo',
],
+ '/:user_id/notify/test': [ // user_id | 'me'
+ ['middleware::api:Permission', { check: 'v1:profile:get' }],
+ 'controller::api:v1:Profile.test_notify',
+ ],
},
patch: {
@@ -29,6 +37,10 @@ const profile_routes = {
['middleware::api:Permission', { check: 'v1:profile:update' }],
'controller::api:v1:Profile.update',
],
+ '/:user_id/notify': [ // user_id | 'me'
+ ['middleware::api:Permission', { check: 'v1:profile:update' }],
+ 'controller::api:v1:Profile.update_notify',
+ ],
},
}
diff --git a/config/notify.config.js b/config/notify.config.js
new file mode 100644
index 0000000..a856e76
--- /dev/null
+++ b/config/notify.config.js
@@ -0,0 +1,16 @@
+// This is the configuration for the Flitter Gotify wrapper service, 'notify'.
+const notify = {
+ // URL to the Gotify host (e.g. https://my-gotify.server.url/)
+ host: env('GOTIFY_HOST'),
+
+ // collection of notification channel groups
+ groups: {
+ // default group. You can specify as many groups as you want.
+ // Each group should be an array of Gotify app keys.
+ default: [
+ env('GOTIFY_DEFAULT_APP_KEY'),
+ ],
+ }
+}
+
+module.exports = exports = notify
diff --git a/locale/en_US/profile.locale.js b/locale/en_US/profile.locale.js
index 2366b92..d58e171 100644
--- a/locale/en_US/profile.locale.js
+++ b/locale/en_US/profile.locale.js
@@ -27,6 +27,15 @@ module.exports = exports = {
many: 'You have NUM_PWS app passwords associated with your account.',
},
+ notifications: 'Notifications',
+ app_key: 'Application Key',
+ example_gateway_url: 'https://gotify.myexample.domain',
+ save_notify: 'Save Notification Settings',
+ test_notify: 'Send Test Notification',
+ notify_explainer_1: `By default, APP_NAME will send you account and security notifications via e-mail. However, if you prefer, you can configure APP_NAME to send you push notifications via a
Gotify notification gateway.`,
+ notify_explainer_2: `To get this up and running, you need to create an application in your Gotify dashboard for APP_NAME. Then, you'll need to provide the URL of your Gotify server and the application key. APP_NAME will use this information to send you push notifications regarding security events.`,
+ test_notification: 'This is a test notification! If you see this, it means you have properly configured your notification settings.',
+
issued: 'Issued:',
gen_new: 'Generate New',
recovery_codes: 'Recovery Codes',
diff --git a/package.json b/package.json
index 4af0123..0c37ba8 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"flitter-di": "^0.5.0",
"flitter-flap": "^0.5.2",
"flitter-forms": "^0.8.1",
+ "flitter-gotify": "^0.1.0",
"flitter-i18n": "^0.1.1",
"flitter-jobs": "^0.1.2",
"flitter-less": "^0.5.3",
diff --git a/yarn.lock b/yarn.lock
index a7fd993..10eb396 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -714,6 +714,13 @@ axios@^0.19.0:
follow-redirects "1.5.10"
is-buffer "^2.0.2"
+axios@^0.19.2:
+ version "0.19.2"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"
+ integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==
+ dependencies:
+ follow-redirects "1.5.10"
+
babel-core@^5.4.7:
version "5.8.38"
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-5.8.38.tgz#1fcaee79d7e61b750b00b8e54f6dfc9d0af86558"
@@ -2122,6 +2129,17 @@ flitter-forms@^0.8.1:
recursive-readdir "^2.2.2"
validator "^10.11.0"
+flitter-gotify@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/flitter-gotify/-/flitter-gotify-0.1.0.tgz#04f3645157ed84a5a54d3df18c4fe8e4579acc3c"
+ integrity sha512-BX16NTmykjairjLojDPtLZQhMH3A4q6KoSZqL88WicgLf3tFEhfEM1RDV/CNeYvPAi0scViEpseaxnQ0kWAPOw==
+ dependencies:
+ axios "^0.19.2"
+ chai "^4.2.0"
+ mocha "^7.0.1"
+ ncp "^2.0.0"
+ sinon "^9.0.0"
+
flitter-i18n@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/flitter-i18n/-/flitter-i18n-0.1.1.tgz#852f916fc643e47c355fcef63d3761edf5bf4f22"