##// END OF EJS Templates
notifications: support real-time notifications with websockets via channelstream
ergo -
r526:1b57d2ee default
parent child
Show More
@@ -0,0 +1,79
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import os
22
23 from pyramid.settings import asbool
24
25 from rhodecode.config.routing import ADMIN_PREFIX
26 from rhodecode.lib.ext_json import json
27
28
29 def url_gen(request):
30 urls = {
31 'connect': request.route_url('channelstream_connect'),
32 'subscribe': request.route_url('channelstream_subscribe')
33 }
34 return json.dumps(urls)
35
36
37 PLUGIN_DEFINITION = {
38 'name': 'channelstream',
39 'config': {
40 'javascript': [],
41 'css': [],
42 'template_hooks': {
43 'plugin_init_template': 'rhodecode:templates/channelstream/plugin_init.html'
44 },
45 'url_gen': url_gen,
46 'static': None,
47 'enabled': False,
48 'server': '',
49 'secret': ''
50 }
51 }
52
53
54 def includeme(config):
55 settings = config.registry.settings
56 PLUGIN_DEFINITION['config']['enabled'] = asbool(
57 settings.get('channelstream.enabled'))
58 PLUGIN_DEFINITION['config']['server'] = settings.get(
59 'channelstream.server', '')
60 PLUGIN_DEFINITION['config']['secret'] = settings.get(
61 'channelstream.secret', '')
62 PLUGIN_DEFINITION['config']['history.location'] = settings.get(
63 'channelstream.history.location', '')
64 config.register_rhodecode_plugin(
65 PLUGIN_DEFINITION['name'],
66 PLUGIN_DEFINITION['config']
67 )
68 # create plugin history location
69 history_dir = PLUGIN_DEFINITION['config']['history.location']
70 if history_dir and not os.path.exists(history_dir):
71 os.makedirs(history_dir, 0750)
72
73 config.add_route(
74 name='channelstream_connect',
75 pattern=ADMIN_PREFIX + '/channelstream/connect')
76 config.add_route(
77 name='channelstream_subscribe',
78 pattern=ADMIN_PREFIX + '/channelstream/subscribe')
79 config.scan('rhodecode.channelstream')
@@ -0,0 +1,177
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 """
22 Channel Stream controller for rhodecode
23
24 :created_on: Oct 10, 2015
25 :author: marcinl
26 :copyright: (c) 2013-2015 RhodeCode GmbH.
27 :license: Commercial License, see LICENSE for more details.
28 """
29
30 import logging
31 import uuid
32
33 from pylons import tmpl_context as c
34 from pyramid.settings import asbool
35 from pyramid.view import view_config
36 from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPBadGateway
37
38 from rhodecode.lib.channelstream import (
39 channelstream_request,
40 ChannelstreamConnectionException,
41 ChannelstreamPermissionException,
42 check_channel_permissions,
43 get_connection_validators,
44 get_user_data,
45 parse_channels_info,
46 update_history_from_logs,
47 STATE_PUBLIC_KEYS)
48 from rhodecode.lib.auth import NotAnonymous
49 from rhodecode.lib.utils2 import str2bool
50
51 log = logging.getLogger(__name__)
52
53
54 class ChannelstreamView(object):
55 def __init__(self, context, request):
56 self.context = context
57 self.request = request
58
59 # Some of the decorators rely on this attribute to be present
60 # on the class of the decorated method.
61 self._rhodecode_user = request.user
62 registry = request.registry
63 self.channelstream_config = registry.rhodecode_plugins['channelstream']
64 if not self.channelstream_config.get('enabled'):
65 log.exception('Channelstream plugin is disabled')
66 raise HTTPBadRequest()
67
68 @NotAnonymous()
69 @view_config(route_name='channelstream_connect', renderer='json')
70 def connect(self):
71 """ handle authorization of users trying to connect """
72 try:
73 json_body = self.request.json_body
74 except Exception:
75 log.exception('Failed to decode json from request')
76 raise HTTPBadRequest()
77 try:
78 channels = check_channel_permissions(
79 json_body.get('channels'),
80 get_connection_validators(self.request.registry))
81 except ChannelstreamPermissionException:
82 log.error('Incorrect permissions for requested channels')
83 raise HTTPForbidden()
84
85 user = c.rhodecode_user
86 if user.user_id:
87 user_data = get_user_data(user.user_id)
88 else:
89 user_data = {
90 'id': None,
91 'username': None,
92 'first_name': None,
93 'last_name': None,
94 'icon_link': None,
95 'display_name': None,
96 'display_link': None,
97 }
98 payload = {
99 'username': user.username,
100 'user_state': user_data,
101 'conn_id': str(uuid.uuid4()),
102 'channels': channels,
103 'channel_configs': {},
104 'state_public_keys': STATE_PUBLIC_KEYS,
105 'info': {
106 'exclude_channels': ['broadcast']
107 }
108 }
109 filtered_channels = [channel for channel in channels
110 if channel != 'broadcast']
111 for channel in filtered_channels:
112 payload['channel_configs'][channel] = {
113 'notify_presence': True,
114 'history_size': 100,
115 'store_history': True,
116 'broadcast_presence_with_user_lists': True
117 }
118 # connect user to server
119 try:
120 connect_result = channelstream_request(self.channelstream_config,
121 payload, '/connect')
122 except ChannelstreamConnectionException:
123 log.exception('Channelstream service is down')
124 return HTTPBadGateway()
125
126 connect_result['channels'] = channels
127 connect_result['channels_info'] = parse_channels_info(
128 connect_result['channels_info'],
129 include_channel_info=filtered_channels)
130 update_history_from_logs(self.channelstream_config,
131 filtered_channels, connect_result)
132 return connect_result
133
134 @NotAnonymous()
135 @view_config(route_name='channelstream_subscribe', renderer='json')
136 def subscribe(self):
137 """ can be used to subscribe specific connection to other channels """
138 try:
139 json_body = self.request.json_body
140 except Exception:
141 log.exception('Failed to decode json from request')
142 raise HTTPBadRequest()
143 try:
144 channels = check_channel_permissions(
145 json_body.get('channels'),
146 get_connection_validators(self.request.registry))
147 except ChannelstreamPermissionException:
148 log.error('Incorrect permissions for requested channels')
149 raise HTTPForbidden()
150 payload = {'conn_id': json_body.get('conn_id', ''),
151 'channels': channels,
152 'channel_configs': {},
153 'info': {
154 'exclude_channels': ['broadcast']}
155 }
156 filtered_channels = [chan for chan in channels if chan != 'broadcast']
157 for channel in filtered_channels:
158 payload['channel_configs'][channel] = {
159 'notify_presence': True,
160 'history_size': 100,
161 'store_history': True,
162 'broadcast_presence_with_user_lists': True
163 }
164 try:
165 connect_result = channelstream_request(
166 self.channelstream_config, payload, '/subscribe')
167 except ChannelstreamConnectionException:
168 log.exception('Channelstream service is down')
169 return HTTPBadGateway()
170 # include_channel_info will limit history only to new channel
171 # to not overwrite histories on other channels in client
172 connect_result['channels_info'] = parse_channels_info(
173 connect_result['channels_info'],
174 include_channel_info=filtered_channels)
175 update_history_from_logs(self.channelstream_config,
176 filtered_channels, connect_result)
177 return connect_result
@@ -0,0 +1,219
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2016-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import logging
22 import os
23
24 import itsdangerous
25 import requests
26
27 from dogpile.core import ReadWriteMutex
28
29 import rhodecode.lib.helpers as h
30
31 from rhodecode.lib.auth import HasRepoPermissionAny
32 from rhodecode.lib.ext_json import json
33 from rhodecode.model.db import User
34
35 log = logging.getLogger(__name__)
36
37 LOCK = ReadWriteMutex()
38
39 STATE_PUBLIC_KEYS = ['id', 'username', 'first_name', 'last_name',
40 'icon_link', 'display_name', 'display_link']
41
42
43 class ChannelstreamException(Exception):
44 pass
45
46
47 class ChannelstreamConnectionException(Exception):
48 pass
49
50
51 class ChannelstreamPermissionException(Exception):
52 pass
53
54
55 def channelstream_request(config, payload, endpoint, raise_exc=True):
56 signer = itsdangerous.TimestampSigner(config['secret'])
57 sig_for_server = signer.sign(endpoint)
58 secret_headers = {'x-channelstream-secret': sig_for_server,
59 'x-channelstream-endpoint': endpoint,
60 'Content-Type': 'application/json'}
61 req_url = 'http://{}{}'.format(config['server'], endpoint)
62 response = None
63 try:
64 response = requests.post(req_url, data=json.dumps(payload),
65 headers=secret_headers).json()
66 except requests.ConnectionError:
67 log.exception('ConnectionError happened')
68 if raise_exc:
69 raise ChannelstreamConnectionException()
70 except Exception:
71 log.exception('Exception related to channelstream happened')
72 if raise_exc:
73 raise ChannelstreamConnectionException()
74 return response
75
76
77 def get_user_data(user_id):
78 user = User.get(user_id)
79 return {
80 'id': user.user_id,
81 'username': user.username,
82 'first_name': user.name,
83 'last_name': user.lastname,
84 'icon_link': h.gravatar_url(user.email, 14),
85 'display_name': h.person(user, 'username_or_name_or_email'),
86 'display_link': h.link_to_user(user),
87 }
88
89
90 def broadcast_validator(channel_name):
91 """ checks if user can access the broadcast channel """
92 if channel_name == 'broadcast':
93 return True
94
95
96 def repo_validator(channel_name):
97 """ checks if user can access the broadcast channel """
98 channel_prefix = '/repo$'
99 if channel_name.startswith(channel_prefix):
100 elements = channel_name[len(channel_prefix):].split('$')
101 repo_name = elements[0]
102 can_access = HasRepoPermissionAny(
103 'repository.read',
104 'repository.write',
105 'repository.admin')(repo_name)
106 log.debug('permission check for {} channel '
107 'resulted in {}'.format(repo_name, can_access))
108 if can_access:
109 return True
110 return False
111
112
113 def check_channel_permissions(channels, plugin_validators, should_raise=True):
114 valid_channels = []
115
116 validators = [broadcast_validator, repo_validator]
117 if plugin_validators:
118 validators.extend(plugin_validators)
119 for channel_name in channels:
120 is_valid = False
121 for validator in validators:
122 if validator(channel_name):
123 is_valid = True
124 break
125 if is_valid:
126 valid_channels.append(channel_name)
127 else:
128 if should_raise:
129 raise ChannelstreamPermissionException()
130 return valid_channels
131
132
133 def get_channels_info(self, channels):
134 payload = {'channels': channels}
135 # gather persistence info
136 return channelstream_request(self._config(), payload, '/info')
137
138
139 def parse_channels_info(info_result, include_channel_info=None):
140 """
141 Returns data that contains only secure information that can be
142 presented to clients
143 """
144 include_channel_info = include_channel_info or []
145
146 user_state_dict = {}
147 for userinfo in info_result['users']:
148 user_state_dict[userinfo['user']] = {
149 k: v for k, v in userinfo['state'].items()
150 if k in STATE_PUBLIC_KEYS
151 }
152
153 channels_info = {}
154
155 for c_name, c_info in info_result['channels'].items():
156 if c_name not in include_channel_info:
157 continue
158 connected_list = []
159 for userinfo in c_info['users']:
160 connected_list.append({
161 'user': userinfo['user'],
162 'state': user_state_dict[userinfo['user']]
163 })
164 channels_info[c_name] = {'users': connected_list,
165 'history': c_info['history']}
166
167 return channels_info
168
169
170 def log_filepath(history_location, channel_name):
171 filename = '{}.log'.format(channel_name.encode('hex'))
172 filepath = os.path.join(history_location, filename)
173 return filepath
174
175
176 def read_history(history_location, channel_name):
177 filepath = log_filepath(history_location, channel_name)
178 if not os.path.exists(filepath):
179 return []
180 history_lines_limit = -100
181 history = []
182 with open(filepath, 'rb') as f:
183 for line in f.readlines()[history_lines_limit:]:
184 try:
185 history.append(json.loads(line))
186 except Exception:
187 log.exception('Failed to load history')
188 return history
189
190
191 def update_history_from_logs(config, channels, payload):
192 history_location = config.get('history.location')
193 for channel in channels:
194 history = read_history(history_location, channel)
195 payload['channels_info'][channel]['history'] = history
196
197
198 def write_history(config, message):
199 """ writes a messge to a base64encoded filename """
200 history_location = config.get('history.location')
201 if not os.path.exists(history_location):
202 return
203 try:
204 LOCK.acquire_write_lock()
205 filepath = log_filepath(history_location, message['channel'])
206 with open(filepath, 'ab') as f:
207 json.dump(message, f)
208 f.write('\n')
209 finally:
210 LOCK.release_write_lock()
211
212
213 def get_connection_validators(registry):
214 validators = []
215 for k, config in registry.rhodecode_plugins.iteritems():
216 validator = config.get('channelstream', {}).get('connect_validator')
217 if validator:
218 validators.append(validator)
219 return validators
@@ -0,0 +1,268
1 // Mix-ins
2 .borderRadius(@radius) {
3 -moz-border-radius: @radius;
4 -webkit-border-radius: @radius;
5 border-radius: @radius;
6 }
7
8 .boxShadow(@boxShadow) {
9 -moz-box-shadow: @boxShadow;
10 -webkit-box-shadow: @boxShadow;
11 box-shadow: @boxShadow;
12 }
13
14 .opacity(@opacity) {
15 @opacityPercent: @opacity * 100;
16 opacity: @opacity;
17 -ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{opacityPercent})";
18 filter: ~"alpha(opacity=@{opacityPercent})";
19 }
20
21 .wordWrap(@wordWrap: break-word) {
22 -ms-word-wrap: @wordWrap;
23 word-wrap: @wordWrap;
24 }
25
26 // Variables
27 @black: #000000;
28 @grey: #999999;
29 @light-grey: #CCCCCC;
30 @white: #FFFFFF;
31 @near-black: #030303;
32 @green: #51A351;
33 @red: #BD362F;
34 @blue: #2F96B4;
35 @orange: #F89406;
36 @default-container-opacity: .8;
37
38 // Styles
39 .toast-title {
40 font-weight: bold;
41 }
42
43 .toast-message {
44 .wordWrap();
45
46 a,
47 label {
48 color: @near-black;
49 }
50
51 a:hover {
52 color: @light-grey;
53 text-decoration: none;
54 }
55 }
56
57 .toast-close-button {
58 position: relative;
59 right: -0.3em;
60 top: -0.3em;
61 float: right;
62 font-size: 20px;
63 font-weight: bold;
64 color: @black;
65 -webkit-text-shadow: 0 1px 0 rgba(255,255,255,1);
66 text-shadow: 0 1px 0 rgba(255,255,255,1);
67 .opacity(0.8);
68
69 &:hover,
70 &:focus {
71 color: @black;
72 text-decoration: none;
73 cursor: pointer;
74 .opacity(0.4);
75 }
76 }
77
78 /*Additional properties for button version
79 iOS requires the button element instead of an anchor tag.
80 If you want the anchor version, it requires `href="#"`.*/
81 button.toast-close-button {
82 padding: 0;
83 cursor: pointer;
84 background: transparent;
85 border: 0;
86 -webkit-appearance: none;
87 }
88
89 //#endregion
90
91 .toast-top-center {
92 top: 0;
93 right: 0;
94 width: 100%;
95 }
96
97 .toast-bottom-center {
98 bottom: 0;
99 right: 0;
100 width: 100%;
101 }
102
103 .toast-top-full-width {
104 top: 0;
105 right: 0;
106 width: 100%;
107 }
108
109 .toast-bottom-full-width {
110 bottom: 0;
111 right: 0;
112 width: 100%;
113 }
114
115 .toast-top-left {
116 top: 12px;
117 left: 12px;
118 }
119
120 .toast-top-right {
121 top: 12px;
122 right: 12px;
123 }
124
125 .toast-bottom-right {
126 right: 12px;
127 bottom: 12px;
128 }
129
130 .toast-bottom-left {
131 bottom: 12px;
132 left: 12px;
133 }
134
135 #toast-container {
136 position: fixed;
137 z-index: 999999;
138 // The container should not be clickable.
139 pointer-events: none;
140 * {
141 -moz-box-sizing: border-box;
142 -webkit-box-sizing: border-box;
143 box-sizing: border-box;
144 }
145
146 > div {
147 position: relative;
148 // The toast itself should be clickable.
149 pointer-events: auto;
150 overflow: hidden;
151 margin: 0 0 6px;
152 padding: 15px;
153 width: 300px;
154 .borderRadius(1px 1px 1px 1px);
155 background-position: 15px center;
156 background-repeat: no-repeat;
157 color: @near-black;
158 .opacity(@default-container-opacity);
159 }
160
161 > :hover {
162 .opacity(1);
163 cursor: pointer;
164 }
165
166 > .toast-info {
167 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important;
168 }
169
170 > .toast-error {
171 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important;
172 }
173
174 > .toast-success {
175 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important;
176 }
177
178 > .toast-warning {
179 //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important;
180 }
181
182 /*overrides*/
183 &.toast-top-center > div,
184 &.toast-bottom-center > div {
185 width: 400px;
186 margin-left: auto;
187 margin-right: auto;
188 }
189
190 &.toast-top-full-width > div,
191 &.toast-bottom-full-width > div {
192 width: 96%;
193 margin-left: auto;
194 margin-right: auto;
195 }
196 }
197
198 .toast {
199 border-color: @near-black;
200 border-style: solid;
201 border-width: 2px 2px 2px 25px;
202 background-color: @white;
203 }
204
205 .toast-success {
206 border-color: @green;
207 }
208
209 .toast-error {
210 border-color: @red;
211 }
212
213 .toast-info {
214 border-color: @blue;
215 }
216
217 .toast-warning {
218 border-color: @orange;
219 }
220
221 .toast-progress {
222 position: absolute;
223 left: 0;
224 bottom: 0;
225 height: 4px;
226 background-color: @black;
227 .opacity(0.4);
228 }
229
230 /*Responsive Design*/
231
232 @media all and (max-width: 240px) {
233 #toast-container {
234
235 > div {
236 padding: 8px;
237 width: 11em;
238 }
239
240 & .toast-close-button {
241 right: -0.2em;
242 top: -0.2em;
243 }
244 }
245 }
246
247 @media all and (min-width: 241px) and (max-width: 480px) {
248 #toast-container {
249 > div {
250 padding: 8px;
251 width: 18em;
252 }
253
254 & .toast-close-button {
255 right: -0.2em;
256 top: -0.2em;
257 }
258 }
259 }
260
261 @media all and (min-width: 481px) and (max-width: 768px) {
262 #toast-container {
263 > div {
264 padding: 15px;
265 width: 25em;
266 }
267 }
268 }
@@ -0,0 +1,435
1 /*
2 * Toastr
3 * Copyright 2012-2015
4 * Authors: John Papa, Hans Fjällemark, and Tim Ferrell.
5 * All Rights Reserved.
6 * Use, reproduction, distribution, and modification of this code is subject to the terms and
7 * conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php
8 *
9 * ARIA Support: Greta Krafsig
10 *
11 * Project: https://github.com/CodeSeven/toastr
12 */
13 /* global define */
14 (function (define) {
15 define(['jquery'], function ($) {
16 return (function () {
17 var $container;
18 var listener;
19 var toastId = 0;
20 var toastType = {
21 error: 'error',
22 info: 'info',
23 success: 'success',
24 warning: 'warning'
25 };
26
27 var toastr = {
28 clear: clear,
29 remove: remove,
30 error: error,
31 getContainer: getContainer,
32 info: info,
33 options: {},
34 subscribe