add support for Gotify push notifications

This commit is contained in:
Garrett Mills 2020-09-02 08:27:09 -05:00
parent 7117099993
commit 3ce470a9b2
Signed by: garrettmills
GPG Key ID: D2BF5FBA8298F246
17 changed files with 374 additions and 1 deletions

View File

@ -31,6 +31,7 @@ const FlitterUnits = {
* Custom units that modify or add functionality that needs to be made
* available to the middleware-routing-controller stack.
*/
'Notify' : require('flitter-gotify/src/unit/NotifyUnit.js'),
'Locale' : require('flitter-i18n/src/LocaleUnit'),
'Redis' : require('flitter-redis/src/RedisUnit'),
'Jobs' : require('flitter-jobs/src/JobsUnit'),

View File

@ -142,6 +142,39 @@ const template = `
<button class="btn btn-sm btn-success" @click="on_mfa_recovery_generate">{{ t['profile.regenerate_recovery'] }}</button>
</span>
</li>
<li class="list-group-item" v-if="notify_loaded">
<h4>{{ t['profile.notifications'] }}</h4>
<p v-html="t['profile.notify_explainer_1'].replace(/APP_NAME/g, app_name)"></p>
<p>{{ t['profile.notify_explainer_2'].replace(/APP_NAME/g, app_name) }}</p>
<div class="row">
<div class="col-12 form-group">
<input
type="text"
class="form-control"
:placeholder="t['profile.example_gateway_url']"
id="coreid-profile-notify-gateway-url"
v-model="notify_gateway_url"
>
</div>
<div class="col-12 form-group">
<input
type="text"
class="form-control"
:placeholder="t['profile.app_key']"
id="coreid-profile-notify-app-key"
v-model="notify_app_key"
>
</div>
<div class="col-12 mt-2">
<button class="btn btn-sm btn-primary" @click="on_notifications_save">{{ t['profile.save_notify'] }}</button>
<button
class="btn btn-sm btn-secondary"
v-if="notify_app_key && notify_gateway_url && notify_enabled"
@click="send_test_notification"
>{{ t['profile.test_notify'] }}</button>
</div>
</div>
</li>
</ul>
</div>
<coreid-form-app-password
@ -178,6 +211,12 @@ export default class EditProfileComponent extends Component {
has_mfa = false
ready = false
notify_gateway_url = ''
notify_app_key = ''
notify_enabled = false
notify_created_on = ''
notify_loaded = false
app_passwords = []
app_name = ''
t = {}
@ -221,7 +260,14 @@ export default class EditProfileComponent extends Component {
'profile.app_pw_3',
'profile.mfa_1',
'profile.mfa_2',
'mfa.enable'
'mfa.enable',
'profile.notifications',
'profile.notify_explainer_1',
'profile.notify_explainer_2',
'profile.app_key',
'profile.example_gateway_url',
'profile.save_notify',
'profile.test_notify'
)
this.app_name = session.get('app.name')
@ -291,6 +337,20 @@ export default class EditProfileComponent extends Component {
this.profile_email = result.email
this.profile_tagline = result.tagline
const notify_config = await profile_service.get_notify(this.user_id || 'me')
if ( !notify_config || !notify_config.has_config ) {
this.notify_app_key = ''
this.notify_enabled = false
this.notify_created_on = ''
this.notify_gateway_url = ''
} else if ( notify_config && notify_config.has_config ) {
this.notify_app_key = notify_config.config.application_key
this.notify_enabled = notify_config.config.active
this.notify_created_on = notify_config.config.created_on
this.notify_gateway_url = notify_config.config.gateway_url
}
this.notify_loaded = true
if ( !this.user_id || this.user_id === 'me' ) {
const reset = (await password_service.get_resets()).reverse()[0]
if (reset && reset.reset_on) {
@ -421,5 +481,20 @@ export default class EditProfileComponent extends Component {
],
})
}
async on_notifications_save() {
const data = {
user_id: this.user_id || 'me',
app_key: this.notify_app_key,
gateway_url: this.notify_gateway_url,
}
await profile_service.update_notify(data)
await this.load()
}
async send_test_notification() {
await profile_service.test_notify({ user_id: this.user_id || 'me' })
}
}

View File

@ -5,10 +5,23 @@ class ProfileService {
if ( results && results.data && results.data.data ) return results.data.data
}
async get_notify(user_id = 'me') {
const results = await axios.get(`/api/v1/profile/${user_id}/notify`)
if ( results && results.data && results.data.data ) return results.data.data
}
async update_profile({ user_id, first_name, last_name, email, tagline = undefined }) {
await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline })
}
async update_notify({ user_id = 'me', app_key, gateway_url }) {
await axios.patch(`/api/v1/profile/${user_id}/notify`, { app_key, gateway_url })
}
async test_notify({ user_id = 'me' }) {
await axios.post(`/api/v1/profile/${user_id}/notify/test`)
}
}
const profile_service = new ProfileService()

View File

@ -0,0 +1,28 @@
/*! ========================================================================
* Bootstrap Toggle: bootstrap-toggle.css v2.2.0
* http://www.bootstraptoggle.com
* ========================================================================
* Copyright 2014 Min Hur, The New York Times Company
* Licensed under MIT
* ======================================================================== */
.checkbox label .toggle,.checkbox-inline .toggle{margin-left:-20px;margin-right:5px}
.toggle{position:relative;overflow:hidden}
.toggle input[type=checkbox]{display:none}
.toggle-group{position:absolute;width:200%;top:0;bottom:0;left:0;transition:left .35s;-webkit-transition:left .35s;-moz-user-select:none;-webkit-user-select:none}
.toggle.off .toggle-group{left:-100%}
.toggle-on{position:absolute;top:0;bottom:0;left:0;right:50%;margin:0;border:0;border-radius:0}
.toggle-off{position:absolute;top:0;bottom:0;left:50%;right:0;margin:0;border:0;border-radius:0}
.toggle-handle{position:relative;margin:0 auto;padding-top:0;padding-bottom:0;height:100%;width:0;border-width:0 1px}
.toggle.btn{min-width:59px;min-height:34px}
.toggle-on.btn{padding-right:24px}
.toggle-off.btn{padding-left:24px}
.toggle.btn-lg{min-width:79px;min-height:45px}
.toggle-on.btn-lg{padding-right:31px}
.toggle-off.btn-lg{padding-left:31px}
.toggle-handle.btn-lg{width:40px}
.toggle.btn-sm{min-width:50px;min-height:30px}
.toggle-on.btn-sm{padding-right:20px}
.toggle-off.btn-sm{padding-left:20px}
.toggle.btn-xs{min-width:35px;min-height:22px}
.toggle-on.btn-xs{padding-right:12px}
.toggle-off.btn-xs{padding-left:12px}

View File

@ -0,0 +1,9 @@
/*! ========================================================================
* Bootstrap Toggle: bootstrap-toggle.js v2.2.0
* http://www.bootstraptoggle.com
* ========================================================================
* Copyright 2014 Min Hur, The New York Times Company
* Licensed under MIT
* ======================================================================== */
+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.toggle"),f="object"==typeof b&&b;e||d.data("bs.toggle",e=new c(this,f)),"string"==typeof b&&e[b]&&e[b]()})}var c=function(b,c){this.$element=a(b),this.options=a.extend({},this.defaults(),c),this.render()};c.VERSION="2.2.0",c.DEFAULTS={on:"On",off:"Off",onstyle:"primary",offstyle:"default",size:"normal",style:"",width:null,height:null},c.prototype.defaults=function(){return{on:this.$element.attr("data-on")||c.DEFAULTS.on,off:this.$element.attr("data-off")||c.DEFAULTS.off,onstyle:this.$element.attr("data-onstyle")||c.DEFAULTS.onstyle,offstyle:this.$element.attr("data-offstyle")||c.DEFAULTS.offstyle,size:this.$element.attr("data-size")||c.DEFAULTS.size,style:this.$element.attr("data-style")||c.DEFAULTS.style,width:this.$element.attr("data-width")||c.DEFAULTS.width,height:this.$element.attr("data-height")||c.DEFAULTS.height}},c.prototype.render=function(){this._onstyle="btn-"+this.options.onstyle,this._offstyle="btn-"+this.options.offstyle;var b="large"===this.options.size?"btn-lg":"small"===this.options.size?"btn-sm":"mini"===this.options.size?"btn-xs":"",c=a('<label class="btn">').html(this.options.on).addClass(this._onstyle+" "+b),d=a('<label class="btn">').html(this.options.off).addClass(this._offstyle+" "+b+" active"),e=a('<span class="toggle-handle btn btn-default">').addClass(b),f=a('<div class="toggle-group">').append(c,d,e),g=a('<div class="toggle btn" data-toggle="toggle">').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

View File

@ -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')

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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

View File

@ -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,
}}
}

View File

@ -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

View File

@ -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',
],
},
}

16
config/notify.config.js Normal file
View File

@ -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

View File

@ -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 <a href="https://gotify.net/" target="_blank" rel="noopener noreferrer">Gotify notification gateway.</a>`,
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',

View File

@ -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",

View File

@ -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"