Merge remote-tracking branch 'upstream/master' into elb-ssl-automation
This commit is contained in:
commit
96c3ab7f9d
|
@ -27,7 +27,10 @@ function browserSyncInit(baseDir, files, browser) {
|
||||||
browserSync.instance = browserSync.init(files, {
|
browserSync.instance = browserSync.init(files, {
|
||||||
startPath: '/index.html',
|
startPath: '/index.html',
|
||||||
server: {
|
server: {
|
||||||
baseDir: baseDir
|
baseDir: baseDir,
|
||||||
|
routes: {
|
||||||
|
'/bower_components': './bower_components'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
browser: browser,
|
browser: browser,
|
||||||
ghostMode: false
|
ghostMode: false
|
||||||
|
|
|
@ -16,25 +16,19 @@ operator_permission = Permission(RoleNeed('operator'))
|
||||||
admin_permission = Permission(RoleNeed('admin'))
|
admin_permission = Permission(RoleNeed('admin'))
|
||||||
|
|
||||||
CertificateCreator = namedtuple('certificate', ['method', 'value'])
|
CertificateCreator = namedtuple('certificate', ['method', 'value'])
|
||||||
CertificateCreatorNeed = partial(CertificateCreator, 'certificateView')
|
CertificateCreatorNeed = partial(CertificateCreator, 'key')
|
||||||
|
|
||||||
CertificateOwner = namedtuple('certificate', ['method', 'value'])
|
|
||||||
CertificateOwnerNeed = partial(CertificateOwner, 'certificateView')
|
|
||||||
|
|
||||||
|
|
||||||
class ViewKeyPermission(Permission):
|
class ViewKeyPermission(Permission):
|
||||||
def __init__(self, certificate_id, owner_id):
|
def __init__(self, certificate_id, owner):
|
||||||
c_need = CertificateCreatorNeed(str(certificate_id))
|
c_need = CertificateCreatorNeed(str(certificate_id))
|
||||||
o_need = CertificateOwnerNeed(str(owner_id))
|
super(ViewKeyPermission, self).__init__(c_need, RoleNeed(owner), RoleNeed('admin'))
|
||||||
|
|
||||||
super(ViewKeyPermission, self).__init__(o_need, c_need, RoleNeed('admin'))
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCertificatePermission(Permission):
|
class UpdateCertificatePermission(Permission):
|
||||||
def __init__(self, role_id, certificate_id):
|
def __init__(self, certificate_id, owner):
|
||||||
c_need = CertificateCreatorNeed(str(certificate_id))
|
c_need = CertificateCreatorNeed(str(certificate_id))
|
||||||
o_need = CertificateOwnerNeed(str(role_id))
|
super(UpdateCertificatePermission, self).__init__(c_need, RoleNeed(owner), RoleNeed('admin'))
|
||||||
super(UpdateCertificatePermission, self).__init__(o_need, c_need, RoleNeed('admin'))
|
|
||||||
|
|
||||||
|
|
||||||
RoleUser = namedtuple('role', ['method', 'value'])
|
RoleUser = namedtuple('role', ['method', 'value'])
|
||||||
|
|
|
@ -29,7 +29,7 @@ from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
|
||||||
|
|
||||||
from lemur.users import service as user_service
|
from lemur.users import service as user_service
|
||||||
from lemur.auth.permissions import CertificateOwnerNeed, CertificateCreatorNeed, \
|
from lemur.auth.permissions import CertificateCreatorNeed, \
|
||||||
AuthorityCreatorNeed, ViewRoleCredentialsNeed
|
AuthorityCreatorNeed, ViewRoleCredentialsNeed
|
||||||
|
|
||||||
|
|
||||||
|
@ -165,7 +165,6 @@ def on_identity_loaded(sender, identity):
|
||||||
# identity with the roles that the user provides
|
# identity with the roles that the user provides
|
||||||
if hasattr(user, 'roles'):
|
if hasattr(user, 'roles'):
|
||||||
for role in user.roles:
|
for role in user.roles:
|
||||||
identity.provides.add(CertificateOwnerNeed(role.id))
|
|
||||||
identity.provides.add(ViewRoleCredentialsNeed(role.id))
|
identity.provides.add(ViewRoleCredentialsNeed(role.id))
|
||||||
identity.provides.add(RoleNeed(role.name))
|
identity.provides.add(RoleNeed(role.name))
|
||||||
|
|
||||||
|
|
|
@ -446,13 +446,14 @@ class CertificatePrivateKey(AuthenticatedResource):
|
||||||
|
|
||||||
role = role_service.get_by_name(cert.owner)
|
role = role_service.get_by_name(cert.owner)
|
||||||
|
|
||||||
permission = ViewKeyPermission(certificate_id, hasattr(role, 'id'))
|
if role:
|
||||||
|
permission = ViewKeyPermission(certificate_id, role.name)
|
||||||
|
|
||||||
if permission.can():
|
if permission.can():
|
||||||
response = make_response(jsonify(key=cert.private_key), 200)
|
response = make_response(jsonify(key=cert.private_key), 200)
|
||||||
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
||||||
response.headers['pragma'] = 'no-cache'
|
response.headers['pragma'] = 'no-cache'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
return dict(message='You are not authorized to view this key'), 403
|
return dict(message='You are not authorized to view this key'), 403
|
||||||
|
|
||||||
|
@ -572,7 +573,7 @@ class Certificates(AuthenticatedResource):
|
||||||
|
|
||||||
cert = service.get(certificate_id)
|
cert = service.get(certificate_id)
|
||||||
role = role_service.get_by_name(cert.owner)
|
role = role_service.get_by_name(cert.owner)
|
||||||
permission = UpdateCertificatePermission(certificate_id, hasattr(role, 'id'))
|
permission = UpdateCertificatePermission(certificate_id, role.name)
|
||||||
|
|
||||||
if permission.can():
|
if permission.can():
|
||||||
return service.update(
|
return service.update(
|
||||||
|
|
|
@ -9,10 +9,9 @@
|
||||||
|
|
||||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
"""
|
"""
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
from sqlalchemy import exc
|
from sqlalchemy import exc
|
||||||
from sqlalchemy.sql import and_, or_
|
from sqlalchemy.sql import and_, or_
|
||||||
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
|
||||||
from lemur.extensions import db
|
from lemur.extensions import db
|
||||||
from lemur.exceptions import AttrNotFound, DuplicateError
|
from lemur.exceptions import AttrNotFound, DuplicateError
|
||||||
|
@ -126,8 +125,7 @@ def get(model, value, field="id"):
|
||||||
query = session_query(model)
|
query = session_query(model)
|
||||||
try:
|
try:
|
||||||
return query.filter(getattr(model, field) == value).one()
|
return query.filter(getattr(model, field) == value).one()
|
||||||
except Exception as e:
|
except NoResultFound as e:
|
||||||
current_app.logger.exception(e)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,6 @@ LEMUR_RESTRICTED_DOMAINS = []
|
||||||
|
|
||||||
LEMUR_EMAIL = ''
|
LEMUR_EMAIL = ''
|
||||||
LEMUR_SECURITY_TEAM_EMAIL = []
|
LEMUR_SECURITY_TEAM_EMAIL = []
|
||||||
LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS = [30, 15, 2]
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
|
|
||||||
|
@ -172,18 +171,17 @@ def generate_settings():
|
||||||
|
|
||||||
|
|
||||||
@manager.option('-s', '--sources', dest='labels', default='', required=False)
|
@manager.option('-s', '--sources', dest='labels', default='', required=False)
|
||||||
@manager.option('-l', '--list', dest='view', default=False, required=False)
|
def sync_sources(labels):
|
||||||
def sync_sources(labels, view):
|
|
||||||
"""
|
"""
|
||||||
Attempts to run several methods Certificate discovery. This is
|
Attempts to run several methods Certificate discovery. This is
|
||||||
run on a periodic basis and updates the Lemur datastore with the
|
run on a periodic basis and updates the Lemur datastore with the
|
||||||
information it discovers.
|
information it discovers.
|
||||||
"""
|
"""
|
||||||
if view:
|
if not labels:
|
||||||
sys.stdout.write("Active\tLabel\tDescription\n")
|
sys.stdout.write("Active\tLabel\tDescription\n")
|
||||||
for source in source_service.get_all():
|
for source in source_service.get_all():
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
"[{active}]\t{label}\t{description}!\n".format(
|
"{active}\t{label}\t{description}!\n".format(
|
||||||
label=source.label,
|
label=source.label,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
active=source.active
|
active=source.active
|
||||||
|
|
|
@ -38,7 +38,10 @@ def _get_message_data(cert):
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
cert_dict = cert.as_dict()
|
cert_dict = cert.as_dict()
|
||||||
cert_dict['creator'] = cert.user.email
|
|
||||||
|
if cert.user:
|
||||||
|
cert_dict['creator'] = cert.user.email
|
||||||
|
|
||||||
cert_dict['domains'] = [x .name for x in cert.domains]
|
cert_dict['domains'] = [x .name for x in cert.domains]
|
||||||
cert_dict['superseded'] = list(set([x.name for x in _find_superseded(cert) if cert.name != x]))
|
cert_dict['superseded'] = list(set([x.name for x in _find_superseded(cert) if cert.name != x]))
|
||||||
return cert_dict
|
return cert_dict
|
||||||
|
@ -56,13 +59,18 @@ def _deduplicate(messages):
|
||||||
|
|
||||||
for m, r, o in roll_ups:
|
for m, r, o in roll_ups:
|
||||||
if r == targets:
|
if r == targets:
|
||||||
m.append(data)
|
for cert in m:
|
||||||
current_app.logger.info(
|
if cert['body'] == data['body']:
|
||||||
"Sending expiration alert about {0} to {1}".format(
|
break
|
||||||
data['name'], ",".join(targets)))
|
else:
|
||||||
|
m.append(data)
|
||||||
|
current_app.logger.info(
|
||||||
|
"Sending expiration alert about {0} to {1}".format(
|
||||||
|
data['name'], ",".join(targets)))
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
roll_ups.append(([data], targets, options))
|
roll_ups.append(([data], targets, options))
|
||||||
|
|
||||||
return roll_ups
|
return roll_ups
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
"""
|
"""
|
||||||
|
from boto.exception import BotoServerError
|
||||||
from lemur.plugins.bases import DestinationPlugin, SourcePlugin
|
from lemur.plugins.bases import DestinationPlugin, SourcePlugin
|
||||||
from lemur.plugins.lemur_aws import iam, elb
|
from lemur.plugins.lemur_aws import iam, elb
|
||||||
from lemur.plugins import lemur_aws as aws
|
from lemur.plugins import lemur_aws as aws
|
||||||
|
@ -42,7 +43,11 @@ class AWSDestinationPlugin(DestinationPlugin):
|
||||||
# }
|
# }
|
||||||
|
|
||||||
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
|
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
|
||||||
iam.upload_cert(find_value('accountNumber', options), name, body, private_key, cert_chain=cert_chain)
|
try:
|
||||||
|
iam.upload_cert(find_value('accountNumber', options), name, body, private_key, cert_chain=cert_chain)
|
||||||
|
except BotoServerError as e:
|
||||||
|
if e.error_code != 'EntityAlreadyExists':
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
e = find_value('elb', options)
|
e = find_value('elb', options)
|
||||||
if e:
|
if e:
|
||||||
|
|
|
@ -326,11 +326,11 @@ class CloudCASourcePlugin(SourcePlugin, CloudCA):
|
||||||
'pollRate': {'type': 'int', 'default': '60'}
|
'pollRate': {'type': 'int', 'default': '60'}
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_certificates(self, **kwargs):
|
def get_certificates(self, options, **kwargs):
|
||||||
certs = []
|
certs = []
|
||||||
for authority in self.get_authorities():
|
for authority in self.get_authorities():
|
||||||
certs += self.get_cert(ca_name=authority)
|
certs += self.get_cert(ca_name=authority)
|
||||||
return
|
return certs
|
||||||
|
|
||||||
def get_cert(self, ca_name=None, cert_handle=None):
|
def get_cert(self, ca_name=None, cert_handle=None):
|
||||||
"""
|
"""
|
||||||
|
@ -355,7 +355,7 @@ class CloudCASourcePlugin(SourcePlugin, CloudCA):
|
||||||
|
|
||||||
certs.append({
|
certs.append({
|
||||||
'public_certificate': cert,
|
'public_certificate': cert,
|
||||||
'intermediate_cert': "\n".join(intermediates),
|
'intermediate_certificate': "\n".join(intermediates),
|
||||||
'owner': c['ownerEmail']
|
'owner': c['ownerEmail']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -53,9 +53,9 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
|
|
||||||
# jinja template depending on type
|
# jinja template depending on type
|
||||||
template = env.get_template('{}.html'.format(event_type))
|
template = env.get_template('{}.html'.format(event_type))
|
||||||
body = template.render(**kwargs)
|
body = template.render(dict(messages=message, hostname=current_app.config.get('LEMUR_HOSTNAME')))
|
||||||
|
|
||||||
s_type = current_app.config.get("LEMUR_EMAIL_SENDER").lower()
|
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", 'ses').lower()
|
||||||
if s_type == 'ses':
|
if s_type == 'ses':
|
||||||
conn = boto.connect_ses()
|
conn = boto.connect_ses()
|
||||||
conn.send_email(current_app.config.get("LEMUR_EMAIL"), subject, body, targets, format='html')
|
conn.send_email(current_app.config.get("LEMUR_EMAIL"), subject, body, targets, format='html')
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from jinja2 import Environment, PackageLoader
|
import os
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
loader = PackageLoader('lemur')
|
loader = FileSystemLoader(searchpath=os.path.dirname(os.path.realpath(__file__)))
|
||||||
env = Environment(loader=loader)
|
env = Environment(loader=loader)
|
||||||
|
|
|
@ -52,8 +52,13 @@
|
||||||
<span style="color: #29abe0">Notice: Your SSL certificates are expiring!</span>
|
<span style="color: #29abe0">Notice: Your SSL certificates are expiring!</span>
|
||||||
<hr />
|
<hr />
|
||||||
</div>
|
</div>
|
||||||
Lemur, Netflix's SSL management portal has noticed that the following certificates are expiring soon, if you rely on these certificates
|
<p>
|
||||||
you should create new certificates to replace the certificates that are expiring. Visit https://lemur.netflix.com/#/certificates/create to reissue them.
|
Lemur, Netflix's SSL management portal has noticed that the following certificates are expiring soon, if you rely on these certificates
|
||||||
|
you should create new certificates to replace the certificates that are expiring.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Visit https://{{ hostname }}/#/certificates/create to reissue them.
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
@ -78,6 +83,12 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ message.creator }}</td>
|
<td>{{ message.creator }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Description</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{{ message.description }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Not Before</strong></td>
|
<td><strong>Not Before</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -104,20 +115,6 @@
|
||||||
<td>Unknown</td>
|
<td>Unknown</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
|
||||||
<td><strong>Associated ELBs</strong></td>
|
|
||||||
</tr>
|
|
||||||
{% if message.listeners %}
|
|
||||||
{% for name in message.listeners %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ name }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td>None</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>Potentially Superseded by</strong> (Lemur's best guess)</td>
|
<td><strong>Potentially Superseded by</strong> (Lemur's best guess)</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -139,7 +136,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 0px" align="center" valign="top">
|
<td style="padding-top: 0px" align="center" valign="top">
|
||||||
<em style="font-style:italic; font-size: 12px; color: #aaa;">Lemur is broken regularly by <a style="color: #29abe0; text-decoration: none;" href="mailto:secops@netflix.com">Security Operations</a></em>
|
<em style="font-style:italic; font-size: 12px; color: #aaa;">Lemur is broken regularly by Netflix</em>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -39,6 +39,7 @@ def _disassociate_certs_from_source(current_certificates, found_certificates, so
|
||||||
|
|
||||||
def sync_create(certificate, source):
|
def sync_create(certificate, source):
|
||||||
cert = cert_service.import_certificate(**certificate)
|
cert = cert_service.import_certificate(**certificate)
|
||||||
|
cert.description = "This certificate was automatically discovered by Lemur"
|
||||||
cert.sources.append(source)
|
cert.sources.append(source)
|
||||||
sync_update_destination(cert, source)
|
sync_update_destination(cert, source)
|
||||||
database.update(cert)
|
database.update(cert)
|
||||||
|
|
|
@ -107,7 +107,6 @@ angular.module('lemur')
|
||||||
title: certificate.name,
|
title: certificate.name,
|
||||||
body: 'Successfully created!'
|
body: 'Successfully created!'
|
||||||
});
|
});
|
||||||
$location.path('/certificates');
|
|
||||||
},
|
},
|
||||||
function (response) {
|
function (response) {
|
||||||
toaster.pop({
|
toaster.pop({
|
||||||
|
@ -120,14 +119,21 @@ angular.module('lemur')
|
||||||
};
|
};
|
||||||
|
|
||||||
CertificateService.update = function (certificate) {
|
CertificateService.update = function (certificate) {
|
||||||
return LemurRestangular.copy(certificate).put().then(function () {
|
return LemurRestangular.copy(certificate).put().then(
|
||||||
toaster.pop({
|
function () {
|
||||||
type: 'success',
|
toaster.pop({
|
||||||
title: certificate.name,
|
type: 'success',
|
||||||
body: 'Successfully updated!'
|
title: certificate.name,
|
||||||
|
body: 'Successfully updated!'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
function (response) {
|
||||||
|
toaster.pop({
|
||||||
|
type: 'error',
|
||||||
|
title: certificate.name,
|
||||||
|
body: 'Failed to update ' + response.data.message
|
||||||
|
});
|
||||||
});
|
});
|
||||||
$location.path('certificates');
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CertificateService.upload = function (certificate) {
|
CertificateService.upload = function (certificate) {
|
||||||
|
@ -138,7 +144,6 @@ angular.module('lemur')
|
||||||
title: certificate.name,
|
title: certificate.name,
|
||||||
body: 'Successfully uploaded!'
|
body: 'Successfully uploaded!'
|
||||||
});
|
});
|
||||||
$location.path('/certificates');
|
|
||||||
},
|
},
|
||||||
function (response) {
|
function (response) {
|
||||||
toaster.pop({
|
toaster.pop({
|
||||||
|
|
|
@ -34,16 +34,6 @@ angular.module('lemur')
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
PluginService.getByType('destination').then(function (plugins) {
|
|
||||||
$scope.plugins = plugins;
|
|
||||||
_.each($scope.plugins, function (plugin) {
|
|
||||||
if (plugin.slug === $scope.destination.pluginName) {
|
|
||||||
plugin.pluginOptions = $scope.destination.destinationOptions;
|
|
||||||
$scope.destination.plugin = plugin;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$scope.save = function (destination) {
|
$scope.save = function (destination) {
|
||||||
DestinationService.update(destination).then(function () {
|
DestinationService.update(destination).then(function () {
|
||||||
$modalInstance.close();
|
$modalInstance.close();
|
||||||
|
|
|
@ -21,7 +21,7 @@ angular.module('lemur')
|
||||||
}, {
|
}, {
|
||||||
total: 0, // length of data
|
total: 0, // length of data
|
||||||
getData: function ($defer, params) {
|
getData: function ($defer, params) {
|
||||||
DomainApi.getList().then(function (data) {
|
DomainApi.getList(params.url()).then(function (data) {
|
||||||
params.total(data.total);
|
params.total(data.total);
|
||||||
$defer.resolve(data);
|
$defer.resolve(data);
|
||||||
});
|
});
|
||||||
|
|
|
@ -87,7 +87,7 @@
|
||||||
</div>
|
</div>
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="text-muted">Lemur is maintained by <a href="mailto:secops@netflix.com">Security Operations</a>.</p>
|
<p class="text-muted">Lemur is broken regularly by <a href="https://github.com/Netflix/lemur.git">Netflix</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -15,6 +15,6 @@ def get_key():
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return current_app.config.get('LEMUR_ENCRYPTION_KEY')
|
return current_app.config.get('LEMUR_ENCRYPTION_KEY').strip()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return ''
|
return ''
|
||||||
|
|
Loading…
Reference in New Issue