diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/amavisd/__init__.py b/controllers/amavisd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/amavisd/api_wblist.py b/controllers/amavisd/api_wblist.py new file mode 100644 index 0000000..d022d10 --- /dev/null +++ b/controllers/amavisd/api_wblist.py @@ -0,0 +1,200 @@ +import web + +from controllers.utils import api_render + +from libs import iredutils, form_utils +from libs.amavisd import wblist as lib_wblist +import settings + +session = web.config.get('_session') + + +if settings.backend == 'ldap': + from libs.ldaplib.general import is_domain_admin +else: + from libs.sqllib.general import is_domain_admin + + +def verify_permission(account): + account = str(account).lower() + + if account == 'global': + if not session.get('is_global_admin'): + return False, 'PERMISSION_DENIED' + + wblist_account = '@.' + else: + if iredutils.is_domain(account): + domain = account + wblist_account = '@' + account + elif iredutils.is_email(account): + domain = account.split('@', 1)[-1] + wblist_account = account + else: + return False, 'INVALID_ACCOUNT' + + if not is_domain_admin(domain=domain, admin=session.get('username'), conn=None): + return False, 'PERMISSION_DENIED' + + return True, wblist_account + + +def get_inout_wb(inout, wb): + _is_in_wl = False + _is_in_bl = False + _is_out_wl = False + _is_out_bl = False + if inout == 'inbound': + if wb == 'whitelist': + _is_in_wl = True + else: + _is_in_bl = True + else: + if wb == 'whitelist': + _is_out_wl = True + else: + _is_out_bl = True + + return {'is_in_wl': _is_in_wl, + 'is_in_bl': _is_in_bl, + 'is_out_wl': _is_out_wl, + 'is_out_bl': _is_out_bl} + + +class APIWBList: + def GET(self, inout, wb, account): + """Get existing wblist. + + curl -X GET -i -b cookie.txt https:///api/wblist/inbound/whitelist/global + curl -X GET -i -b cookie.txt https:///api/wblist/inbound/blacklist/global + curl -X GET -i -b cookie.txt https:///api/wblist/outbound/whitelist/global + curl -X GET -i -b cookie.txt https:///api/wblist/outbound/blacklist/global + """ + _qr = verify_permission(account) + if not _qr[0]: + return api_render(_qr) + + wblist_account = _qr[1] + inout_wb = get_inout_wb(inout=inout, wb=wb) + + qr = lib_wblist.get_wblist( + account=wblist_account, + whitelist=inout_wb['is_in_wl'], + blacklist=inout_wb['is_in_bl'], + outbound_whitelist=inout_wb['is_out_wl'], + outbound_blacklist=inout_wb['is_out_bl'], + ) + + if not qr[0]: + return api_render(qr) + + result = qr[1] + if inout_wb['is_in_wl']: + addresses = result['inbound_whitelists'] + elif inout_wb['is_in_bl']: + addresses = result['inbound_blacklists'] + elif inout_wb['is_out_wl']: + addresses = result['outbound_whitelists'] + else: + # inout_wb['is_out_bl'] + addresses = result['outbound_blacklists'] + + return api_render((True, addresses)) + + def POST(self, inout, wb, account): + """Create new wblist. + + curl -X POST ... \ + -d "addresses=user@domain.com,user2@domain.com" \ + https:///api/wblist/inbound/whitelist/global + + curl -X POST ... \ + -d "addresses=user@domain.com,user2@domain.com" \ + https:///api/wblist/inbound/blacklist/global + + curl -X POST ... \ + -d "addresses=user@domain.com,user2@domain.com" \ + https:///api/wblist/outbound/whitelist/global + + curl -X POST ... \ + -d "addresses=user@domain.com,user2@domain.com" \ + https:///api/wblist/outbound/blacklist/global + """ + _qr = verify_permission(account) + if not _qr[0]: + return api_render(_qr) + + wblist_account = _qr[1] + inout_wb = get_inout_wb(inout=inout, wb=wb) + + form = web.input(_unicode=False) + _addresses = form_utils.get_multi_values_from_api(form=form, input_name='addresses') + _addresses = [i for i in _addresses if iredutils.is_valid_amavisd_address(i)] + + d = {} + for (k, v) in list(inout_wb.items()): + _name = k.replace("is_", "") + if v is True: + d[_name] = _addresses + else: + d[_name] = None + + qr = lib_wblist.add_wblist( + account=wblist_account, + wl_senders=d["in_wl"], + bl_senders=d["in_bl"], + wl_rcpts=d["out_wl"], + bl_rcpts=d["out_bl"], + flush_before_import=False, + ) + + return api_render(qr) + + def PUT(self, inout, wb, account): + # Delete addresses + _qr = verify_permission(account) + if not _qr[0]: + return api_render(_qr) + + wblist_account = _qr[1] + inout_wb = get_inout_wb(inout=inout, wb=wb) + + form = web.input(_unicode=False) + _addresses = form_utils.get_multi_values_from_api(form=form, input_name='addresses') + _addresses = [i for i in _addresses if iredutils.is_valid_amavisd_address(i)] + + d = {} + for (k, v) in list(inout_wb.items()): + _name = k.replace("is_", "") + if v is True: + d[_name] = _addresses + else: + d[_name] = None + + qr = lib_wblist.delete_wblist( + account=wblist_account, + wl_senders=d["in_wl"], + bl_senders=d["in_bl"], + wl_rcpts=d["out_wl"], + bl_rcpts=d["out_bl"], + ) + + return api_render(qr) + + def DELETE(self, inout, wb, account): + _qr = verify_permission(account) + if not _qr[0]: + return api_render(_qr) + + wblist_account = _qr[1] + inout_wb = get_inout_wb(inout=inout, wb=wb) + + qr = lib_wblist.delete_all_wblist( + account=wblist_account, + wl_senders=inout_wb['is_in_wl'], + bl_senders=inout_wb['is_in_bl'], + wl_rcpts=inout_wb['is_out_wl'], + bl_rcpts=inout_wb['is_out_bl'], + ) + + return api_render(qr) diff --git a/controllers/amavisd/log.py b/controllers/amavisd/log.py new file mode 100644 index 0000000..1da13f8 --- /dev/null +++ b/controllers/amavisd/log.py @@ -0,0 +1,591 @@ +# Author: Zhang Huangbin + +import web +import settings +from controllers import decorators +from libs import iredutils +from libs.mailparser import parse_raw_message +from libs.amavisd import QUARANTINE_TYPES +from libs.amavisd import log as lib_log +from libs.amavisd import quarantine as lib_quarantine +from libs.amavisd import wblist as lib_wblist + +session = web.config.get('_session') + + +DELETE_ACTION_MSGS = { + 'release': 'RELEASED', + 'release_whitelist_sender': 'RELEASED_WL_SENDER', + 'release_whitelist_sender_domain': 'RELEASED_WL_SENDER_DOMAIN', + 'release_whitelist_sender_subdomain': 'RELEASED_WL_SENDER_SUBDOMAIN', + 'delete': 'DELETED', + 'deleteAll': 'DELETED', + # log_type == 'received' + 'delete_whitelist_sender': 'DELETED_WL_SENDER', + 'delete_whitelist_sender_domain': 'DELETED_WL_SENDER_DOMAIN', + 'delete_whitelist_sender_subdomain': 'DELETED_WL_SENDER_SUBDOMAIN', + 'delete_blacklist_sender': 'DELETED_BL_SENDER', + 'delete_blacklist_sender_domain': 'DELETED_BL_SENDER_DOMAIN', + 'delete_blacklist_sender_subdomain': 'DELETED_BL_SENDER_SUBDOMAIN', + # log_type == 'sent' + 'delete_whitelist_rcpt': 'DELETED_WL_RCPT', + 'delete_whitelist_rcpt_domain': 'DELETED_WL_RCPT_DOMAIN', + 'delete_whitelist_rcpt_subdomain': 'DELETED_WL_RCPT_SUBDOMAIN', + 'delete_blacklist_rcpt': 'DELETED_BL_RCPT', + 'delete_blacklist_rcpt_domain': 'DELETED_BL_RCPT_DOMAIN', + 'delete_blacklist_rcpt_subdomain': 'DELETED_BL_RCPT_SUBDOMAIN', +} + + +class InOutMails: + @decorators.require_permission_in_session(perm='disable_viewing_mail_log', not_present=True) + @decorators.require_admin_login + def GET(self, log_type='sent', page=1): + log_type = str(log_type) + + # Get current page. + page = int(page) or 1 + + qr = lib_log.get_in_out_mails(log_type=log_type, cur_page=page) + if qr[0]: + total = qr[1]['count'] + records = qr[1]['records'] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + return web.render( + 'amavisd/inout.html', + log_type=log_type, + cur_page=page, + account_type=None, + account=None, + total=total, + records=records, + removeLogsInDays=settings.AMAVISD_REMOVE_MAILLOG_IN_DAYS, + msg=web.input().get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_permission_in_session(perm='disable_viewing_mail_log', not_present=True) + @decorators.require_admin_login + def POST(self, log_type='sent', page=1): + # Get current page. + page = int(page) or 1 + redirect_url = '/activities/%s/page/%d' % (log_type, page) + + form = web.input(record=[], _unicode=False) + action = form.get('action', 'delete') + + if not action.startswith('delete'): + raise web.seeother(redirect_url + '?msg=INVALID_ACTION') + + mailids = [] + addresses = [] + for r in form.get('record', []): + # record format: mail_id + \r\n + sender + tmp = r.split(r'\r\n') + if len(tmp) == 2: + (mid, addr) = tmp + mailids.append(mid) + + if iredutils.is_email(addr): + if action.endswith('_sender') or action.endswith('_rcpt'): + addresses.append(addr) + elif action.endswith('_domain'): + addresses.append('@' + addr.split('@', 1)[-1]) + elif action.endswith('_subdomain'): + addresses.append('@.' + addr.split('@', 1)[-1]) + + if (not mailids) and (action != 'deleteAll'): + raise web.seeother(redirect_url + '?msg=INVALID_MAILID') + + if action == 'deleteAll': + qr_del = lib_log.delete_all_records(log_type=log_type, account=None) + else: + # delete records by mailids + qr_del = lib_log.delete_records_by_mail_id(log_type=log_type, mail_ids=mailids) + + if not qr_del[0]: + raise web.seeother(redirect_url + '?msg=' + web.urlquote(qr_del[1])) + + # Add server-wide white/blacklists. + # Note: if admin is a normal admin, we don't know which domain he + # manages, so cannot add per-domain white/blacklists here. + if session.get('is_global_admin') and addresses: + wblist_account = '@.' + + # whitelist recipients + if action.startswith('delete_whitelist'): + qr_wblist = lib_wblist.add_wblist(account=wblist_account, wl_senders=addresses) + + elif action.startswith('delete_blacklist'): + qr_wblist = lib_wblist.add_wblist(account=wblist_account, bl_senders=addresses) + else: + qr_wblist = (False, 'INVALID_ACTION') + + if not qr_wblist[0]: + raise web.seeother(redirect_url + '?msg=' + web.urlquote(qr_wblist[1])) + + raise web.seeother(redirect_url + '?msg=' + DELETE_ACTION_MSGS[action]) + + +class InOutMailsPerAccount: + @decorators.require_permission_in_session(perm='disable_viewing_mail_log', not_present=True) + @decorators.require_login + def GET(self, log_type, account_type, account, page=1): + log_type = str(log_type) + account_type = str(account_type) + account = str(account) + page = int(page) or 1 + + # Verify account syntax + if account_type == 'domain': + if not iredutils.is_domain(account): + raise web.seeother('/activities/%s?msg=INVALID_DOMAIN_NAME' % log_type) + elif account_type == 'user': + if not iredutils.is_email(account): + raise web.seeother('/activities/%s?msg=INVALID_MAIL' % log_type) + + qr = lib_log.get_in_out_mails(log_type=log_type, + cur_page=page, + account_type=account_type, + account=account) + + if qr[0]: + total = qr[1]['count'] + records = qr[1]['records'] + else: + raise web.seeother('/activities/{}?msg={}'.format(log_type, web.urlquote(qr[1]))) + + return web.render( + 'amavisd/inout.html', + log_type=log_type, + cur_page=page, + account_type=account_type, + account=account, + total=total, + records=records, + removeLogsInDays=settings.AMAVISD_REMOVE_MAILLOG_IN_DAYS, + msg=web.input().get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_permission_in_session(perm='disable_viewing_mail_log', not_present=True) + @decorators.require_login + def POST(self, log_type, account_type, account, page=1): + log_type = str(log_type).lower() + account_type = str(account_type).lower() + account = str(account).lower() + page = int(page) or 1 + redirect_url = '/activities/{}/{}/{}/page/{}'.format(log_type, account_type, account, page) + + form = web.input(record=[], _unicode=False) + action = str(form.get('action', '')) + + if not action.startswith('delete'): + raise web.seeother(redirect_url + '?msg=INVALID_ACTION') + + mailids = [] + addresses = [] + for r in form.get('record', []): + # record format: mail_id + \r\n + sender + tmp = r.split(r'\r\n') + if len(tmp) == 2: + (mid, addr) = tmp + mailids.append(mid) + + if iredutils.is_email(addr): + if action.endswith('_sender') or action.endswith('_rcpt'): + addresses.append(addr) + elif action.endswith('_domain'): + addresses.append('@' + addr.split('@', 1)[-1]) + elif action.endswith('_subdomain'): + addresses.append('@.' + addr.split('@', 1)[-1]) + + if (not mailids) and (action != 'deleteAll'): + raise web.seeother(redirect_url + '?msg=INVALID_MAILID') + + if action == 'deleteAll': + qr_del = lib_log.delete_all_records(log_type=log_type, account=account) + else: + # delete records by mailids + qr_del = lib_log.delete_records_by_mail_id(log_type=log_type, mail_ids=mailids) + + if not qr_del[0]: + raise web.seeother(redirect_url + '?msg=' + web.urlquote(qr_del[1])) + + # Add server-wide white/blacklists. + # Note: if admin is a normal admin, we don't know which domain he + # manages, so cannot add per-domain white/blacklists here. + if addresses and \ + (action.startswith('delete_whitelist') or action.startswith('delete_blacklist')): + wblist_account = None + _do_wb = False + if session.get('is_global_admin'): + # Global wblist + wblist_account = account + _do_wb = True + elif session.get('account_is_mail_user'): + # per-account wblist + wblist_account = session['username'] + _do_wb = True + + if _do_wb: + # whitelist recipients + if action.startswith('delete_whitelist'): + qr_wblist = lib_wblist.add_wblist(account=wblist_account, wl_senders=addresses) + + elif action.startswith('delete_blacklist'): + qr_wblist = lib_wblist.add_wblist(account=wblist_account, bl_senders=addresses) + else: + qr_wblist = (False, 'INVALID_ACTION') + + if not qr_wblist[0]: + raise web.seeother(redirect_url + '?msg=' + web.urlquote(qr_wblist[1])) + + raise web.seeother(redirect_url + '?msg=' + DELETE_ACTION_MSGS[action]) + + +class QuarantinedMails: + @decorators.require_permission_in_session(perm='disable_managing_quarantined_mails', not_present=True) + @decorators.require_admin_login + def GET(self, quarantined_type=None, page=1): + form = web.input() + sort_by_score = 'sort_by_score' in form + + # Get current page. + # None means on page 1, e.g. /activities/quarantined + if quarantined_type in QUARANTINE_TYPES or quarantined_type is None: + page = int(page) or 1 + else: + page = int(quarantined_type) or 1 + quarantined_type = None + + qr = lib_quarantine.get_quarantined_mails(quarantined_type=quarantined_type, + page=page, + sort_by_score=sort_by_score) + + if qr[0]: + (total, records) = qr[1] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + return web.render( + 'amavisd/quarantined.html', + account_type=None, + account=None, + quarantined_type=quarantined_type, + cur_page=page, + total=total, + records=records, + removeQuarantinedInDays=settings.AMAVISD_REMOVE_QUARANTINED_IN_DAYS, + sort_by_score=sort_by_score, + msg=form.get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_permission_in_session(perm='disable_managing_quarantined_mails', not_present=True) + @decorators.require_admin_login + def POST(self, quarantined_type=None, page=1): + form = web.input(record=[], _unicode=False) + action = form.get('action', None) + + if quarantined_type not in QUARANTINE_TYPES: + quarantined_type = None + + redirect_url = '/activities/quarantined' + if quarantined_type: + redirect_url = redirect_url + '/' + quarantined_type + + redirect_url += '/page/{}'.format(page) + + if action == 'deleteAll': + if session.get('is_global_admin'): + lib_quarantine.delete_all_quarantined(quarantined_type=quarantined_type) + + raise web.seeother(redirect_url + '?msg=%s' % DELETE_ACTION_MSGS[action]) + + # Get necessary information from web form. + records = [] + mailids = [] + senders = set() + + for r in form.get('record', []): + # record format: mail_id + \r\n + secret_id + \r\n + sender + tmp = r.split(r'\r\n') + if len(tmp) == 3: + records += [{'mail_id': tmp[0], 'secret_id': tmp[1]}] + mailids.append(tmp[0]) + + if iredutils.is_email(tmp[2]): + senders.add(tmp[2]) + + if not mailids: + if not (action == 'deleteAll' and session.get('is_global_admin')): + raise web.seeother(redirect_url + '?msg=INVALID_MAILID') + + if action != 'deleteAll' and not mailids: + raise web.seeother(redirect_url + '?msg=%s' % DELETE_ACTION_MSGS[action]) + + wb_senders = set() + if action in ['release_whitelist_sender', 'delete_blacklist_sender']: + wb_senders = senders + elif action in ['release_whitelist_sender_domain', 'delete_blacklist_sender_domain']: + for s in senders: + wb_senders.add('@' + s.split('@', 1)[-1]) + elif action in ['release_whitelist_sender_subdomain', 'delete_blacklist_sender_subdomain']: + for s in senders: + wb_senders.add('@.' + s.split('@', 1)[-1]) + + wblist_account = '@.' + if session.get('is_global_admin'): + # Add as global wblist + wblist_account = '@.' + elif session.get('is_normal_admin'): + # Add as per-domain wblist + wblist_account = '@' + session['username'].split('@', 1)[-1] + + if action.startswith('release'): + result = lib_quarantine.release_quarantined_mails(records=records) + + if action in ['release_whitelist_sender', + 'release_whitelist_sender_domain', + 'release_whitelist_sender_subdomain']: + # whitelist senders or sender_domains + if wb_senders: + qr = lib_wblist.add_wblist(account=wblist_account, wl_senders=wb_senders) + + if not qr[0]: + result = qr + + elif action.startswith('delete'): + result = lib_log.delete_records_by_mail_id(log_type='quarantine', mail_ids=mailids) + + if action in ['delete_blacklist_sender', + 'delete_blacklist_sender_domain', + 'delete_blacklist_sender_subdomain']: + if wb_senders: + qr = lib_wblist.add_wblist(account=wblist_account, bl_senders=wb_senders) + if not qr[0]: + result = qr + + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + raise web.seeother(redirect_url + '?msg=%s' % DELETE_ACTION_MSGS[action]) + else: + raise web.seeother(redirect_url + '?msg=%s' % web.urlquote(result[1])) + + +class QuarantinedMailsPerAccount: + @decorators.require_permission_in_session(perm='disable_managing_quarantined_mails', not_present=True) + @decorators.require_login + def GET(self, account_type, account, quarantined_type=None, page=1): + account_type = str(account_type) + account = str(account) + + form = web.input() + sort_by_score = 'sort_by_score' in form + + # Normal user login + if session['account_is_mail_user'] and account_type == 'user': + if session['username'] != account: + # Accessing other's quarantined mails + raise web.seeother('/activities/quarantined/user/%s?msg=PERMISSION_DENIED' % session['username']) + if 'quarantine' in session.get('disabled_user_preferences', []): + raise web.seeother('/preferences?msg=PERMISSION_DENIED') + + if quarantined_type: + # Get current page. + if str(quarantined_type).isdigit(): + # According to URL mapping, quarantined_type could be page number. + page = int(quarantined_type) or 1 + else: + page = int(page) or 1 + + if quarantined_type not in QUARANTINE_TYPES: + quarantined_type = None + + qr = lib_quarantine.get_quarantined_mails(account_type=account_type, + account=account, + quarantined_type=quarantined_type, + page=page, + sort_by_score=sort_by_score) + + if qr[0]: + (total, records) = qr[1] + else: + if session['account_is_mail_user']: + raise web.seeother('/preferences?msg=%s' % web.urlquote(qr[1])) + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + template_file = 'amavisd/quarantined.html' + if session['account_is_mail_user']: + template_file = 'amavisd/quarantined_user.html' + + return web.render( + template_file, + account_type=account_type, + account=account, + quarantined_type=quarantined_type, + cur_page=page, + total=total, + records=records, + removeQuarantinedInDays=settings.AMAVISD_REMOVE_QUARANTINED_IN_DAYS, + sort_by_score=sort_by_score, + msg=form.get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_permission_in_session(perm='disable_managing_quarantined_mails', not_present=True) + @decorators.require_login + def POST(self, account_type, account, quarantined_type=None, page=1): + form = web.input(record=[], _unicode=False) + + if quarantined_type: + # Get current page. + if str(quarantined_type).isdigit(): + # According to URL mapping, quarantined_type could be page number. + page = int(quarantined_type) or 1 + else: + page = int(page) or 1 + + if quarantined_type not in QUARANTINE_TYPES: + quarantined_type = None + + redirect_url = '/activities/quarantined' + if account_type and account: + redirect_url = redirect_url + '/{}/{}'.format(account_type, account) + + if quarantined_type: + redirect_url = redirect_url + '/' + quarantined_type + + redirect_url += '/page/{}'.format(page) + action = form.get('action', None) + + # Get necessary information from web form. + records = [] + mailids = [] + senders = set() + + # Get `msgs.mail_id` and `msgs.secret_id` + for r in form.get('record', []): + # record format: mail_id + \r\n + secret_id + \r\n + sender + tmp = r.split(r'\r\n') + if len(tmp) == 3: + records += [{'mail_id': tmp[0], 'secret_id': tmp[1]}] + mailids.append(tmp[0]) + + if iredutils.is_email(tmp[2]): + senders.add(tmp[2]) + + if not mailids: + raise web.seeother(redirect_url + '?msg=INVALID_MAILID') + + wb_senders = set() + if action in ['release_whitelist_sender', 'delete_blacklist_sender']: + wb_senders = senders + elif action in ['release_whitelist_sender_domain', 'delete_blacklist_sender_domain']: + for s in senders: + wb_senders.add('@' + s.split('@', 1)[-1]) + elif action in ['release_whitelist_sender_subdomain', 'delete_blacklist_sender_subdomain']: + for s in senders: + wb_senders.add('@.' + s.split('@', 1)[-1]) + + wblist_account = account + if session.get('is_global_admin'): + # Add as global wblist + wblist_account = '@.' + elif session.get('is_normal_admin'): + # Add as per-domain wblist + wblist_account = '@' + account.split('@', 1)[-1] + + if action.startswith('release'): + result = lib_quarantine.release_quarantined_mails(records=records) + + if action in ['release_whitelist_sender', + 'release_whitelist_sender_domain', + 'release_whitelist_sender_subdomain']: + # whitelist senders or sender_domains + if wb_senders: + qr = lib_wblist.add_wblist(account=wblist_account, wl_senders=wb_senders) + + if not qr[0]: + result = qr + elif action.startswith('delete'): + result = lib_log.delete_records_by_mail_id(log_type='quarantine', mail_ids=mailids) + + if action in ['delete_blacklist_sender', + 'delete_blacklist_sender_domain', + 'delete_blacklist_sender_subdomain']: + # Don't add account domain in blacklist + try: + wb_senders.remove(account.split('@', 1)[-1]) + except: + pass + + if wb_senders: + qr = lib_wblist.add_wblist(account=wblist_account, bl_senders=wb_senders) + if not qr[0]: + result = qr + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + msg = DELETE_ACTION_MSGS[action] + else: + msg = web.urlquote(result[1]) + + raise web.seeother(redirect_url + '?msg=%s' % msg) + + +class GetRawMessageOfQuarantinedMail: + @decorators.require_login + def GET(self, mail_id): + qr = lib_quarantine.get_raw_message(mail_id=mail_id) + + if not qr[0]: + raise web.seeother('/activities/quarantined?msg=%s' % web.urlquote(qr[1])) + + # Parse mail and convert to HTML. + try: + (headers, bodies, attachments) = parse_raw_message(qr[1]) + except Exception as e: + raise web.seeother('/activities/quarantined?msg=%s' % web.urlquote(repr(e))) + + return web.render('amavisd/quarantined_raw.html', + mail_id=mail_id, + headers=headers, + bodies=bodies, + attachments=attachments) + + +class SearchLog: + @decorators.require_admin_login + def GET(self): + raise web.seeother('/activities/sent') + + @decorators.csrf_protected + @decorators.require_admin_login + def POST(self): + form = web.input(_unicode=False) + account = form.get('account', '') + + log_type = 'sent' + if 'received' in form: + log_type = 'received' + elif 'sent' in form: + log_type = 'sent' + elif 'quarantined' in form: + log_type = 'quarantined' + + if iredutils.is_email(account): + account_type = 'user' + elif iredutils.is_domain(account): + account_type = 'domain' + else: + raise web.seeother('/activities/%s?msg=INVALID_ACCOUNT' % log_type) + + raise web.seeother('/activities/{}/{}/{}'.format(log_type, account_type, account)) diff --git a/controllers/amavisd/spampolicy.py b/controllers/amavisd/spampolicy.py new file mode 100644 index 0000000..b3c4df4 --- /dev/null +++ b/controllers/amavisd/spampolicy.py @@ -0,0 +1,182 @@ +# Author: Zhang Huangbin + +import web +import settings +from libs import iredutils +from controllers import decorators +from libs.amavisd import spampolicy as spampolicylib + +# API +from controllers.utils import api_render + +if settings.backend == 'ldap': + from libs.ldaplib.general import is_domain_admin +else: + from libs.sqllib.general import is_domain_admin + + +session = web.config.get('_session') + + +def _check_privilege(admin, account_type, account): + """Check whether current admin has privilege to update account spam policy. + + Return (True, {'account': xx, 'account_type': xx}) if has required privilege. + Return (False, ) if no required privilege. + """ + if account_type == 'global': + account = '@.' + + # Check privilege + if not session.get('is_global_admin'): + return False, 'PERMISSION_DENIED' + elif account_type == 'domain': + domain = account + account = '@' + domain + elif account_type == 'user': + domain = account.split('@', 1)[-1] + else: + return False, 'INVALID_ACCOUNT' + + if account_type in ['domain', 'user']: + # Check whether it's managed by admin + if not is_domain_admin(domain=domain, admin=admin): + return False, 'PERMISSION_DENIED' + + return True, {'account': account, 'account_type': account_type} + + +class SpamPolicy: + def _get_account_and_type(self): + # account, type: + # - @.: global + # - domain.com: domain + # - user@domain.com: user, user_preference + current_url = web.ctx.environ['PATH_INFO'] + if current_url == '/system/spampolicy': + # Global policy + account = '@.' + account_type = 'global' + elif current_url.startswith('/profile/domain'): + # Per-domain policy + account = '@' + current_url.split('/')[-1] + account_type = 'domain' + elif current_url.startswith('/profile/user'): + # per-user policy, modifying by admin. + account = current_url.split('/')[-1] + account_type = 'user' + else: + # per-user preferences + # web.ctx.PATH_INFO == '/preferences/spampolicy' + account = session['username'] + account_type = 'user_preference' + + return {'account': account, + 'account_type': account_type, + 'url': current_url} + + @decorators.require_preference_access('spampolicy') + @decorators.require_login + def GET(self, account=None): + d = self._get_account_and_type() + account = d['account'] + account_type = d['account_type'] + current_url = d['url'] + + if account_type == 'global': + # Check privilege + if not session.get('is_global_admin'): + raise web.seeother('/domains?msg=PERMISSION_DENIED') + elif account_type in ['domain', 'user']: + domain = account.split('@', 1)[-1] + + # Check whether it's managed by admin + if not is_domain_admin(domain=domain, admin=session.get('username')): + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + (success, policy) = spampolicylib.get_spam_policy(account=account) + if not success: + if account_type == 'user_preference': + raise web.seeother('/preferences?msg=%s' % web.urlquote(policy)) + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(policy)) + + global_spam_score = spampolicylib.get_global_spam_score() + + return web.render( + 'amavisd/spampolicy.html', + account_type=account_type, + spampolicy=policy, + global_spam_score=global_spam_score, + custom_ban_rules=settings.AMAVISD_BAN_RULES, + current_url=current_url, + msg=web.input().get('msg'), + ) + + @decorators.require_preference_access('spampolicy') + @decorators.require_login + def POST(self, account=None): + if account: + if iredutils.is_domain(account): + policy_account = '@' + account + current_url = '/profile/domain/spampolicy/' + account + elif iredutils.is_email(account): + policy_account = str(account) + current_url = '/profile/user/spampolicy/' + policy_account + else: + d = self._get_account_and_type() + policy_account = d['account'] + current_url = d['url'] + + form = web.input(banned_rulenames=[]) + + qr = spampolicylib.update_spam_policy(account=policy_account, form=form) + if qr[0]: + raise web.seeother(current_url + '?msg=UPDATED') + else: + raise web.seeother(current_url + '?msg=%s' % web.urlquote(qr[1])) + + +class APISpamPolicy: + @decorators.require_preference_access('spampolicy') + @decorators.require_login + def GET(self, account_type, account=None): + qr = _check_privilege(admin=session.get('username'), + account_type=account_type, + account=account) + if not qr[0]: + return api_render(qr) + + account = qr[1]['account'] + + qr = spampolicylib.get_spam_policy(account=account) + return api_render(qr) + + @decorators.require_preference_access('spampolicy') + @decorators.require_login + def PUT(self, account_type, account=None): + qr = _check_privilege(admin=session.get('username'), + account_type=account_type, + account=account) + if not qr[0]: + return api_render(qr) + + form = web.input(_unicode=False) + + account = qr[1]['account'] + qr = spampolicylib.api_update_spam_policy(account=account, form=form) + return api_render(qr) + + @decorators.require_preference_access('spampolicy') + @decorators.require_login + def DELETE(self, account_type, account=None): + qr = _check_privilege(admin=session.get('username'), + account_type=account_type, + account=account) + if not qr[0]: + return api_render(qr) + + account = qr[1]['account'] + + qr = spampolicylib.delete_spam_policy(account=account) + return api_render(qr) diff --git a/controllers/amavisd/urls.py b/controllers/amavisd/urls.py new file mode 100644 index 0000000..ea09927 --- /dev/null +++ b/controllers/amavisd/urls.py @@ -0,0 +1,73 @@ +# Author: Zhang Huangbin + +import settings +from libs.regxes import email as e, domain as d + +# fmt: off +urls = [ + # Search activity logs. + '/activities/search', 'controllers.amavisd.log.SearchLog', + + # View log of sent/received mails + '/activities/(received|sent)', 'controllers.amavisd.log.InOutMails', + r'/activities/(received|sent)/page/(\d+)', 'controllers.amavisd.log.InOutMails', + + # Per-user activities + '/activities/(received|sent)/(user)/(%s)' % e, 'controllers.amavisd.log.InOutMailsPerAccount', + r'/activities/(received|sent)/(user)/(%s)/page/(\d+)' % e, 'controllers.amavisd.log.InOutMailsPerAccount', + # Per-domain activities + '/activities/(received|sent)/(domain)/(%s)' % d, 'controllers.amavisd.log.InOutMailsPerAccount', + r'/activities/(received|sent)/(domain)/(%s)/page/(\d+)' % d, 'controllers.amavisd.log.InOutMailsPerAccount', + + # Quarantined mails + '/activities/quarantined', 'controllers.amavisd.log.QuarantinedMails', + r'/activities/quarantined/page/(\d+)', 'controllers.amavisd.log.QuarantinedMails', + '/activities/quarantined/(spam|virus|banned|badheader|badmime|clean)', 'controllers.amavisd.log.QuarantinedMails', + r'/activities/quarantined/(spam|virus|banned|badheader|badmime|clean)/page/(\d+)', 'controllers.amavisd.log.QuarantinedMails', + # Per-user quarantined mails + r'/activities/quarantined/(user)/(%s)' % e, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + r'/activities/quarantined/(user)/(%s)/page/(\d+)' % e, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + '/activities/quarantined/(user)/(%s)/(spam|virus|banned|badheader|badmime|clean)' % e, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + r'/activities/quarantined/(user)/(%s)/(spam|virus|banned|badheader|badmime|clean)/page/(\d+)' % e, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + # Per-domain quarantined mails + '/activities/quarantined/(domain)/(%s)' % d, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + r'/activities/quarantined/(domain)/(%s)/page/(\d+)' % d, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + '/activities/quarantined/(domain)/(%s)/(spam|virus|banned|badheader|badmime|clean)' % d, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + r'/activities/quarantined/(domain)/(%s)/(spam|virus|banned|badheader|badmime|clean)/page/(\d+)' % d, 'controllers.amavisd.log.QuarantinedMailsPerAccount', + + # Get RAW message of quarantined mail by mail_id. + '/activities/quarantined/raw/(.*)', 'controllers.amavisd.log.GetRawMessageOfQuarantinedMail', + + # Activity management + '/activities/sender/(%s)' % e, 'controllers.amavisd.log.ActivityManagement', + + # Spam policies. + # Global spam policy (recipient = '@.') + '/system/spampolicy', 'controllers.amavisd.spampolicy.SpamPolicy', + # per-domain spam policy (recipient = '@domain.com') + '/system/spampolicy/(%s$)' % d, 'controllers.amavisd.spampolicy.SpamPolicy', + # per-user spam policy (recipient = '@domain.com') + '/system/spampolicy/(%s$)' % e, 'controllers.amavisd.spampolicy.SpamPolicy', + + # global wblist + '/create/wblist', 'controllers.amavisd.wblist.Create', + '/system/wblist', 'controllers.amavisd.wblist.GlobalWBList', + + # Per-user preferences: wblist, spam control + '/preferences/wblist', 'controllers.amavisd.wblist.UserWBList', + '/preferences/spampolicy', 'controllers.amavisd.spampolicy.SpamPolicy', +] + +# API Interfaces +if settings.ENABLE_RESTFUL_API: + urls += [ + # Global, per-domain, per-user spam policy + '/api/spampolicy/(global)', 'controllers.amavisd.spampolicy.APISpamPolicy', + '/api/spampolicy/(domain)/(%s$)' % d, 'controllers.amavisd.spampolicy.APISpamPolicy', + '/api/spampolicy/(user)/(%s$)' % e, 'controllers.amavisd.spampolicy.APISpamPolicy', + + '/api/wblist/(inbound|outbound)/(whitelist|blacklist)/(global)', 'controllers.amavisd.api_wblist.APIWBList', + '/api/wblist/(inbound|outbound)/(whitelist|blacklist)/(%s$)' % d, 'controllers.amavisd.api_wblist.APIWBList', + '/api/wblist/(inbound|outbound)/(whitelist|blacklist)/(%s$)' % e, 'controllers.amavisd.api_wblist.APIWBList', + ] +# fmt: on diff --git a/controllers/amavisd/wblist.py b/controllers/amavisd/wblist.py new file mode 100644 index 0000000..1290f16 --- /dev/null +++ b/controllers/amavisd/wblist.py @@ -0,0 +1,104 @@ +# Author: Zhang Huangbin + +import web +from controllers import decorators +from libs.amavisd import get_wblist_from_form, wblist as lib_wblist + +session = web.config.get('_session') + + +def render_wblist(account, template): + whitelists = [] + blacklists = [] + outbound_whitelists = [] + outbound_blacklists = [] + + qr = lib_wblist.get_wblist(account=account) + if qr[0]: + whitelists = qr[1]['inbound_whitelists'] + blacklists = qr[1]['inbound_blacklists'] + outbound_whitelists = qr[1]['outbound_whitelists'] + outbound_blacklists = qr[1]['outbound_blacklists'] + + return web.render(template, + whitelists=whitelists, + blacklists=blacklists, + outbound_whitelists=outbound_whitelists, + outbound_blacklists=outbound_blacklists, + msg=web.input().get('msg')) + + +def update_wblist_from_form(form, + account, + post_url, + success_msg, + flush_before_import=False): + wl_senders = get_wblist_from_form(form, 'wl_sender') + bl_senders = get_wblist_from_form(form, 'bl_sender') + wl_rcpts = get_wblist_from_form(form, 'wl_rcpt') + bl_rcpts = get_wblist_from_form(form, 'bl_rcpt') + + qr = lib_wblist.add_wblist(account=account, + wl_senders=wl_senders, + bl_senders=bl_senders, + wl_rcpts=wl_rcpts, + bl_rcpts=bl_rcpts, + flush_before_import=flush_before_import) + + if qr[0]: + raise web.seeother(post_url + '?msg=' + success_msg) + else: + raise web.seeother(post_url + '?msg=%s' % web.urlquote(qr[1])) + + +# Add global white/blacklists +class Create: + @decorators.require_global_admin + def GET(self): + return web.render('amavisd/wblist/create.html', + msg=web.input().get('msg')) + + @decorators.require_global_admin + def POST(self): + form = web.input() + + return update_wblist_from_form(form=form, + account='@.', + post_url='/create/wblist', + success_msg='WBLIST_CREATED', + flush_before_import=False) + + +class GlobalWBList: + @decorators.require_global_admin + def GET(self): + return render_wblist(account='@.', template='amavisd/wblist/global.html') + + @decorators.require_global_admin + def POST(self): + form = web.input() + return update_wblist_from_form(form=form, + account='@.', + post_url='/system/wblist', + success_msg='WBLIST_UPDATED', + flush_before_import=True) + + +class UserWBList: + @decorators.require_preference_access('wblist') + @decorators.require_login + def GET(self): + account = session['username'] + return render_wblist(account=account, + template='amavisd/wblist/user.html') + + @decorators.require_preference_access('wblist') + @decorators.require_login + def POST(self): + account = session['username'] + form = web.input() + return update_wblist_from_form(form=form, + account=account, + post_url='/preferences/wblist', + success_msg='WBLIST_UPDATED', + flush_before_import=True) diff --git a/controllers/decorators.py b/controllers/decorators.py new file mode 100644 index 0000000..5b40848 --- /dev/null +++ b/controllers/decorators.py @@ -0,0 +1,175 @@ +# Author: Zhang Huangbin + +import web +from libs import iredutils +from libs.logger import logger +from controllers.utils import api_render +import settings + +session = web.config.get("_session") + + +def require_login(func): + def proxyfunc(*args, **kw): + if session.get("logged"): + return func(*args, **kw) + else: + session.kill() + raise web.seeother("/login?msg=LOGIN_REQUIRED") + + return proxyfunc + + +def require_admin_login(func): + def proxyfunc(*args, **kw): + if session.get("logged"): + if session.get("is_global_admin") or session.get("is_normal_admin"): + return func(*args, **kw) + else: + if session.get("account_is_mail_user"): + raise web.seeother("/preferences?msg=PERMISSION_DENIED") + else: + raise web.seeother("/domains?msg=PERMISSION_DENIED") + else: + session.kill() + raise web.seeother("/login?msg=LOGIN_REQUIRED") + + return proxyfunc + + +def api_require_admin_login(func): + def proxyfunc(*args, **kw): + if session.get("logged"): + if session.get("is_global_admin") or session.get("is_normal_admin"): + return func(*args, **kw) + else: + session.kill() + return api_render((False, "LOGIN_REQUIRED")) + else: + session.kill() + return api_render((False, "LOGIN_REQUIRED")) + + return proxyfunc + + +def require_global_admin(func): + def proxyfunc(*args, **kw): + if session.get("is_global_admin"): + return func(*args, **kw) + else: + if session.get("logged"): + if session.get("account_is_mail_user"): + raise web.seeother("/preferences?msg=PERMISSION_DENIED") + else: + raise web.seeother("/domains?msg=PERMISSION_DENIED") + else: + raise web.seeother("/login?msg=LOGIN_REQUIRED") + + return proxyfunc + + +def api_require_global_admin(func): + if not iredutils.is_allowed_api_client(web.ctx.ip): + return api_render((False, "NOT_AUTHORIZED")) + + def proxyfunc(*args, **kw): + if session.get("is_global_admin"): + return func(*args, **kw) + else: + if session.get("username"): + return api_render((False, "PERMISSION_DENIED")) + else: + return api_render((False, "LOGIN_REQUIRED")) + + return proxyfunc + + +def require_user_login(func): + def proxyfunc(*args, **kw): + if session.get("account_is_mail_user"): + return func(*args, **kw) + else: + session.kill() + raise web.seeother("/login?msg=LOGIN_REQUIRED") + + return proxyfunc + + +def csrf_protected(f): + def decorated(*args, **kw): + form = web.input() + + if "csrf_token" not in form: + return web.render("error_csrf.html") + + if not session.get("csrf_token"): + session["csrf_token"] = iredutils.generate_random_strings(32) + + if form["csrf_token"] != session["csrf_token"]: + return web.render("error_csrf.html") + + return f(*args, **kw) + + return decorated + + +# Used in user self-service +def require_preference_access(preference): + def proxyfunc1(func): + def proxyfunc2(*args, **kw): + return func(*args, **kw) + + return proxyfunc2 + + if session.get("is_global_admin") or session.get("is_normal_admin"): + return proxyfunc1 + else: + # session.get('account_is_mail_user') + if preference in session.get("disabled_user_preferences", []): + raise web.seeother("/preferences?msg=PERMISSION_DENIED") + else: + return proxyfunc1 + + +def require_permission_create_domain(func): + def proxyfunc(*args, **kw): + if session.get("is_global_admin") or session.get("create_new_domains"): + return func(*args, **kw) + else: + if session.get("account_is_mail_user"): + raise web.seeother("/preferences?msg=PERMISSION_DENIED") + else: + raise web.seeother("/domains?msg=PERMISSION_DENIED") + + return proxyfunc + + +def require_permission_in_session(perm, present=False, not_present=False, value=""): + def proxyfunc(func): + def proxyargs(*args, **kwargs): + if present: + if perm in session: + return func(*args, **kwargs) + + if not_present: + if perm not in session: + return func(*args, **kwargs) + + if value: + if session.get(perm) == value: + return func(*args, **kwargs) + + if settings.LOG_PERMISSION_DENIED: + logger.error("PERMISSION_DENIED raised in decorator " + "@require_permission_in_session: module=%s.py, " + "function=%s(), " + "permission=%s" % (func.__module__, func.__name__, perm)) + + if session.get("account_is_mail_user"): + raise web.seeother("/preferences?msg=PERMISSION_DENIED") + else: + raise web.seeother("/domains?msg=PERMISSION_DENIED") + + return proxyargs + + return proxyfunc diff --git a/controllers/f2b/__init__.py b/controllers/f2b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/f2b/api_log.py b/controllers/f2b/api_log.py new file mode 100644 index 0000000..ab5ac71 --- /dev/null +++ b/controllers/f2b/api_log.py @@ -0,0 +1,11 @@ +from controllers import decorators +from controllers.utils import api_render + +from libs.f2b import log as f2b_log + + +class APIBannedCount: + @decorators.api_require_global_admin + def GET(self): + total = f2b_log.num_banned() + return api_render((True, total)) diff --git a/controllers/f2b/log.py b/controllers/f2b/log.py new file mode 100644 index 0000000..3a379b8 --- /dev/null +++ b/controllers/f2b/log.py @@ -0,0 +1,81 @@ +from base64 import b64decode +import web +from controllers import decorators +from controllers.utils import api_render + +from libs import iredutils +from libs.logger import log_activity + + +class Banned: + @decorators.require_global_admin + def GET(self): + _qr = web.conn_f2b.select( + 'banned', + what='id, ip, rdns, ports, jail, country, failures, timestamp, remove', + order='ip', + ) + rows = list(_qr) + + return web.render('fail2ban/banned.html', rows=rows) + + +class UnbanIP: + """Unban given IP address, or the IP addresses submitted by form. + + Note: It returns JSON. + """ + @decorators.require_global_admin + def DELETE(self, ip=None): + if ip: + ips = [ip] + else: + # Get IP addresses from web form. + form = web.input(ip=[]) + ips = form.get('ip', []) + + ips = [ip for ip in ips if iredutils.is_strict_ip(ip)] + + if not ips: + return api_render(True) + + try: + web.conn_f2b.update( + 'banned', + vars={"ips": ips}, + remove=1, + where="ip IN $ips", + ) + + log_activity(msg="Unbanned: " + ', '.join(ips), + event='unban') + + return api_render(True) + except Exception as e: + return api_render((False, repr(e))) + + +class MatchedLogLines: + @decorators.require_global_admin + def GET(self, record_id): + _qr = web.conn_f2b.select( + 'banned', + vars={'id': record_id}, + what='loglines', + where='id=$id', + limit=1, + ) + + if _qr: + loglines = _qr[0]['loglines'] + + # Assume its base64 encoded, try to decode it. + if loglines: + try: + loglines = iredutils.bytes2str(b64decode(loglines)) + except: + pass + else: + loglines = 'NO_MATCHED_LOG_LINES' + + return web.render('fail2ban/matched_log_lines.html', loglines=loglines) diff --git a/controllers/f2b/urls.py b/controllers/f2b/urls.py new file mode 100644 index 0000000..5633a58 --- /dev/null +++ b/controllers/f2b/urls.py @@ -0,0 +1,14 @@ +# Author: Zhang Huangbin + +# fmt: off +urls = [ + '/activities/fail2ban/banned', 'controllers.f2b.log.Banned', + r'/activities/fail2ban/banned/loglines/(\d+)', 'controllers.f2b.log.MatchedLogLines', + + # Warning: it returns JSON. + '/activities/fail2ban/unbanip/(.*)', 'controllers.f2b.log.UnbanIP', + + # API interfaces used by web ui. + '/api/activities/fail2ban/banned/count', 'controllers.f2b.api_log.APIBannedCount', +] +# fmt: on diff --git a/controllers/iredapd/__init__.py b/controllers/iredapd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/iredapd/api_greylist.py b/controllers/iredapd/api_greylist.py new file mode 100644 index 0000000..6ec07e7 --- /dev/null +++ b/controllers/iredapd/api_greylist.py @@ -0,0 +1,416 @@ +import web +from controllers.utils import api_render +from libs import iredutils +from libs.iredapd import greylist as lib_greylist + +import settings + +if settings.backend == 'ldap': + from libs.ldaplib import decorators +else: + from libs.sqllib import decorators + + +def convert_greylist_setting_to_api_json(greylist_setting=None): + """Return dict with simplified information as API result.""" + if not greylist_setting: + greylist_setting = {} + + _status = greylist_setting.get('active', 'inherit') + + status = 'inherit' + if _status == 1: + status = 'enabled' + elif _status == 0: + status = 'disabled' + + return api_render((True, {'status': status})) + + +class APIAllSettings: + @decorators.api_require_global_admin + def GET(self): + """Get all existing greylisting settings. + + curl -X GET -i -b cookie.txt https:///api/greylisting/all + """ + s = lib_greylist.get_all_greylist_settings() + + _all_settings = {} + for i in s: + _sender = str(i.sender).lower() + _account = str(i.account).lower() + _active = int(i.active) + + _setting = {'sender': _sender, + 'account': _account} + + if _active == 1: + _setting['status'] = 'enabled' + else: + _setting['status'] = 'disabled' + + if _account in _all_settings: + _all_settings[_account] += [_setting] + else: + _all_settings[_account] = [_setting] + + return api_render((True, _all_settings)) + + +class APIGlobalSetting: + @decorators.api_require_global_admin + def GET(self): + """Get global greylisting setting. + + curl -X GET -i -b cookie.txt https:///api/greylisting/global + """ + s = lib_greylist.get_greylist_setting(account='@.') + + # If no greylisting setting, mark it as explicitly disabled. + if not s: + s = {'active': 0} + + return convert_greylist_setting_to_api_json(s) + + @decorators.api_require_global_admin + def POST(self): + """Set global greylisting setting. + + curl -X POST -i -b cookie.txt -d "status=enable" https:///api/greylisting/global + + Required parameters: + + @status -- Explicitly enable or disable greylisting globally. + Possible values: enable, disable. + """ + form = web.input(_unicode=False) + + enable = True + if form.get('status') == 'disable': + enable = False + + qr = lib_greylist.enable_disable_greylist_setting(account='@.', enable=enable) + return api_render(qr) + + +class APIDomainSetting: + @decorators.api_require_domain_access + def GET(self, domain): + """Get per-domain greylisting setting. + + curl -X GET -i -b cookie.txt https:///api/greylisting/ + """ + domain = str(domain).lower() + + s = lib_greylist.get_greylist_setting(account='@' + domain) + return convert_greylist_setting_to_api_json(s) + + @decorators.api_require_domain_access + def POST(self, domain): + """Set per-domain greylisting setting. + + curl -X POST -i -b cookie.txt -d "status=enable" https:///api/greylisting/ + + Required parameters: + + @status -- Explicitly enable or disable greylisting globally. + Possible values: enable, disable. + """ + form = web.input(_unicode=False) + + domain = str(domain).lower() + status = form.get('status', 'inherit').lower() + + if status in ['enable', 'disable']: + enable = (status == 'enable') + qr = lib_greylist.enable_disable_greylist_setting(account='@' + domain, enable=enable) + else: + # Remove setting + qr = lib_greylist.delete_greylist_setting(account='@' + domain) + + return api_render(qr) + + +class APIUserSetting: + @decorators.api_require_domain_access + def GET(self, mail): + """Get per-user greylisting setting. + + curl -X GET -i -b cookie.txt https:///api/greylisting/ + """ + mail = str(mail).lower() + s = lib_greylist.get_greylist_setting(account=mail) + return convert_greylist_setting_to_api_json(s) + + @decorators.api_require_domain_access + def POST(self, mail): + """Set per-user greylisting setting. + + curl -X POST -i -b cookie.txt -d "status=enable" https:///api/greylisting/ + + Required parameters: + + @status -- Explicitly enable or disable greylisting globally. + Possible values: enable, disable. + """ + form = web.input(_unicode=False) + status = form.get('status', 'inherit').lower() + + mail = str(mail).lower() + + if status in ['enable', 'disable']: + enable = (status == 'enable') + qr = lib_greylist.enable_disable_greylist_setting(account=mail, enable=enable) + else: + # Remove setting + qr = lib_greylist.delete_greylist_setting(account=mail) + + return api_render(qr) + + +def _get_account_whitelists(account): + account = str(account).lower() + + if not (iredutils.is_domain(account) + or iredutils.is_email(account) + or account == '@.'): + return False, 'INVALID_ACCOUNT' + + if iredutils.is_domain(account): + account = '@' + account + + wl = lib_greylist.get_greylist_whitelists(account=account, address_only=True) + _result = {'whitelists': wl} + + if account == '@.': + wl_domains = lib_greylist.get_greylist_whitelist_domains() + _result['whitelist_domains'] = wl_domains + + return True, _result + + +def _update_account_whitelists(account, form): + account = str(account).lower() + + if not (iredutils.is_domain(account) + or iredutils.is_email(account) + or account == '@.'): + return False, 'INVALID_ACCOUNT' + + if iredutils.is_domain(account): + account = '@' + account + + if 'senders' in form: + # Reset whitelisted senders + _senders = form.get('senders', '').strip().split(',') + + _senders = [str(i).lower() + for i in _senders + if iredutils.is_valid_wblist_address(i)] + + _senders = list(set(_senders)) + + qr = lib_greylist.reset_greylist_whitelists(account=account, + whitelists=_senders) + if not qr[0]: + return qr + else: + # Add new whitelist senders + _new = [] + if 'addSenders' in form: + _new = form.get('addSenders', '').strip().split(',') + + # Remove existing ones + _removed = [] + if 'removeSenders' in form: + _removed = form.get('removeSenders', '').strip().split(',') + + qr = lib_greylist.update_greylist_whitelists(account=account, + new=_new, + removed=_removed) + + if not qr[0]: + return qr + + return True, + + +class APIGlobalWhitelists: + @decorators.api_require_global_admin + def GET(self): + """Get globally whitelisted senders for greylisting service. + + curl -X GET -i -b cookie.txt https:///api/greylisting/global/whitelists + """ + qr = _get_account_whitelists(account='@.') + return api_render(qr) + + @decorators.api_require_global_admin + def POST(self): + """Set global greylisting setting. + + curl -X POST -i -b cookie.txt -d "var=value&var2=value2" https:///api/greylisting/global/whitelists + + Optional parameters: + + @senders - Reset whitelisted senders for global greylisting + service to given senders. Multiple addresses must + be separated by comma. Conflicts with parameter + `addSenders` and `removeSenders`. + @addSenders - Whitelist new senders for greylisting service + globally. Multiple addresses must be separated by + comma. Conflicts with parameter `senders`. + @removeSenders - Remove existing whitelisted senders for + greylisting service globally. Multiple + addresses must be separated by comma. + Conflicts with parameter `senders`. + """ + form = web.input(_unicode=False) + + qr = _update_account_whitelists(account='@.', form=form) + if not qr[0]: + return api_render(qr) + + return api_render(True) + + +class APIGlobalWhitelist: + """Handle single whitelist.""" + @decorators.api_require_global_admin + def PUT(self, ip): + """ + Whitelist given IP address globally. + curl -X PUT -i -b cookie.txt https:///api/greylisting/global/whitelist/ + """ + qr = lib_greylist.update_greylist_whitelists(account='@.', new=[ip], removed=None) + return api_render(qr) + + +class APIDomainWhitelists: + @decorators.api_require_domain_access + def GET(self, domain): + """Get whitelisted senders for greylisting service for given domain. + + curl -X GET -i -b cookie.txt https:///api/greylisting//whitelists + """ + qr = _get_account_whitelists(account=domain) + return api_render(qr) + + @decorators.api_require_domain_access + def POST(self, domain): + """Set global greylisting setting. + + curl -X POST -i -b cookie.txt -d "var=value&var2=value2" https:///api/greylisting//whitelists + + Optional parameters: + + @senders - Reset whitelisted senders + @addSenders - Whitelist new senders for greylisting service + @removeSenders - Remove existing whitelisted senders + """ + form = web.input(_unicode=False) + + qr = _update_account_whitelists(account=domain, form=form) + if not qr[0]: + return api_render(qr) + + return api_render(True) + + +class APIUserWhitelists: + @decorators.api_require_domain_access + def GET(self, mail): + """Get whitelisted senders for greylisting service for given user. + + curl -X GET -i -b cookie.txt https:///api/greylisting//whitelists + """ + qr = _get_account_whitelists(account=mail) + return api_render(qr) + + @decorators.api_require_domain_access + def POST(self, mail): + """Set global greylisting setting. + + curl -X POST -i -b cookie.txt -d "var=value&var2=value2" https:///api/greylisting//whitelists + + Optional parameters: + + @senders - Reset whitelisted senders + @addSenders - Whitelist new senders for greylisting service + @removeSenders - Remove existing whitelisted senders + """ + form = web.input(_unicode=False) + + qr = _update_account_whitelists(account=mail, form=form) + if not qr[0]: + return api_render(qr) + + return api_render(True) + + +def _update_whitelist_spf_domains(form): + if 'domains' in form: + # Reset + _domains = form.get('domains', '').strip().split(',') + + _domains = [str(i).lower() + for i in _domains + if iredutils.is_domain(i)] + + _domains = list(set(_domains)) + + qr = lib_greylist.reset_greylist_whitelist_domains(domains=_domains) + if not qr[0]: + return qr + else: + # Add new + _new = [] + if 'addDomains' in form: + _new = form.get('addDomains', '').strip().split(',') + + # Remove existing ones + _removed = [] + if 'removeDomains' in form: + _removed = form.get('removeDomains', '').strip().split(',') + + qr = lib_greylist.update_greylist_whitelist_domains(new=_new, removed=_removed) + + if not qr[0]: + return qr + + return True, + + +class APIWhitelistSPFDomain: + @decorators.api_require_global_admin + def GET(self): + """Get whitelisted sender domains (for SPF query) for greylisting service. + + curl -X GET -i -b cookie.txt https:///api/greylisting/whitelist_spf_domains + """ + domains = lib_greylist.get_greylist_whitelist_domains() + return api_render((True, {'domains': domains})) + + @decorators.api_require_global_admin + def POST(self): + """Manage whitelisted sender domains (for SPF query) for greylisting service. + + curl -X POST -i -b cookie.txt -d "var=value&var2=value2" https:///api/greylisting/whitelist_spf_domains + + Optional parameters: + + @domains - Reset sender domains + @addDomains - Add new sender domains + @removeDomains - Remove existing sender domains + + Note: given sender domain names are not used directly while checking + whitelisting, instead, there's a cron job to query SPF and MX + DNS records of given sender domains, then whitelist the IP + addresses/networks listed in DNS records. Multiple domains must + be separated by comma. + + """ + form = web.input(_unicode=False) + qr = _update_whitelist_spf_domains(form) + return api_render(qr) diff --git a/controllers/iredapd/api_throttle.py b/controllers/iredapd/api_throttle.py new file mode 100644 index 0000000..481d59d --- /dev/null +++ b/controllers/iredapd/api_throttle.py @@ -0,0 +1,118 @@ +import web +from controllers.utils import api_render +from libs import form_utils +from libs.iredapd import throttle as iredapd_throttle + +import settings + +if settings.backend == 'ldap': + from libs.ldaplib import decorators +else: + from libs.sqllib import decorators + + +# TODO able to specify quota unit for msg_size and max_quota. e.g. 10MB, 2GB. +# Build form from API POST data and submit the throttle setting +def _add_throttle(form, account, kind): + form['enable_' + kind + '_throttling'] = 'on' + + if 'period' in form: + form[kind + '_period'] = form.pop('period') + else: + return False, 'MISS_PERIOD' + + _has_rule = False + for i in ['msg_size', 'max_quota', 'max_msgs']: + if i in form: + _has_rule = True + + # radio/checkboxes are toggled + form[kind + '_' + i] = 'on' + + # value + form['custom_' + kind + '_' + i] = form.pop(i) + + if not _has_rule: + return False, 'MISS_THROTTLE_SETTING' + + ts = form_utils.get_throttle_setting(form, account=account, inout_type=kind) + qr = iredapd_throttle.add_throttle(account=account, setting=ts, inout_type=kind) + return qr + + +class APIGlobalThrottle: + @decorators.require_global_admin + def GET(self, kind): + """Get global inbound and outbound throttle settings. + + curl -X GET -i -b cookie.txt https:///api/throttle/global/inbound + curl -X GET -i -b cookie.txt https:///api/throttle/global/outbound + """ + ts = iredapd_throttle.get_throttle_setting(account='@.', inout_type=kind) + return api_render({'_success': True, 'setting': ts}) + + @decorators.require_global_admin + def POST(self, kind): + """Set global throttle settings. + + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle/global/inbound + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle/global/outbound + + Required POST parameters: + + @period - Period of time (in seconds) + @msg_size - Max size of single email + @max_msgs - Number of max inbound emails + @max_quota - Cumulative size of all inbound emails + + Note: at least one of msg_size, max_msgs, max_quota is required. + """ + form = web.input(_unicode=False) + qr = _add_throttle(form, account='@.', kind=kind) + return api_render(qr) + + +class APIDomainThrottle: + @decorators.api_require_domain_access + def GET(self, domain, kind): + """Set per-domain throttle settings. + + curl -X GET -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//inbound + curl -X GET -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//outbound + """ + ts = iredapd_throttle.get_throttle_setting(account='@' + domain, inout_type=kind) + return api_render({'_success': True, 'setting': ts}) + + @decorators.api_require_domain_access + def POST(self, domain, kind): + """Set per-domain throttle settings. + + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//inbound + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//outbound + """ + form = web.input(_unicode=False) + qr = _add_throttle(form, account='@' + domain, kind=kind) + return api_render(qr) + + +class APIUserThrottle: + @decorators.api_require_domain_access + def GET(self, mail, kind): + """Set per-user throttle settings. + + curl -X GET -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//inbound + curl -X GET -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//outbound + """ + ts = iredapd_throttle.get_throttle_setting(account=mail, inout_type=kind) + return api_render({'_success': True, 'setting': ts}) + + @decorators.api_require_domain_access + def POST(self, mail, kind): + """Set per-user throttle settings. + + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//inbound + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/throttle//outbound + """ + form = web.input(_unicode=False) + qr = _add_throttle(form, account=mail, kind=kind) + return api_render(qr) diff --git a/controllers/iredapd/greylist.py b/controllers/iredapd/greylist.py new file mode 100644 index 0000000..6152165 --- /dev/null +++ b/controllers/iredapd/greylist.py @@ -0,0 +1,55 @@ +# Author: Zhang Huangbin + +import web +from libs.iredapd import greylist as iredapd_greylist +import settings + +if settings.backend == 'ldap': + from libs.ldaplib import decorators +else: + from libs.sqllib import decorators + + +class DefaultGreylisting: + @decorators.require_global_admin + def GET(self): + gl_setting = iredapd_greylist.get_greylist_setting(account='@.') + gl_whitelists = iredapd_greylist.get_greylist_whitelists(account='@.') + gl_whitelist_domains = iredapd_greylist.get_greylist_whitelist_domains() + + # Get greylisting tracking data + (_status, _result) = iredapd_greylist.get_tracking_data(account='@.') + if not _status: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + else: + tracking_records = _result + + return web.render('iredapd/greylisting_global.html', + gl_setting=gl_setting, + gl_whitelists=gl_whitelists, + gl_whitelist_domains=gl_whitelist_domains, + parent_setting={}, + tracking_records=tracking_records, + msg=web.input().get('msg')) + + @decorators.require_global_admin + def POST(self): + form = web.input() + qr = iredapd_greylist.update_greylist_settings_from_form(account='@.', form=form) + + if qr[0]: + raise web.seeother('/system/greylisting?msg=GL_UPDATED') + else: + raise web.seeother('/system/greylisting?msg=%s' % web.urlquote(qr[1])) + + +class GreylistingRawTrackingData: + @decorators.require_domain_access + def GET(self, domain): + (_status, _result) = iredapd_greylist.get_domain_tracking_data(domain=domain) + if not _status: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + return web.render('iredapd/greylisting_tracking_records.html', + domain=domain, + tracking_records=_result) diff --git a/controllers/iredapd/log.py b/controllers/iredapd/log.py new file mode 100644 index 0000000..6e8a8e8 --- /dev/null +++ b/controllers/iredapd/log.py @@ -0,0 +1,217 @@ +# Author: Zhang Huangbin + +from typing import List +import web +import settings + +from libs.iredapd import log as iredapd_log, wblist_senderscore +from libs.iredapd import greylist as lib_greylist + + +if settings.backend == 'ldap': + from libs.ldaplib import decorators +else: + from libs.sqllib import decorators + + +session = web.config.get('_session') + + +def _filter_whitelisted_senderscore_ips(rows=None) -> List: + # Get IP addresses of rejected sessions due to senderscore. + whitelisted_ips = [] + + try: + _rejected_ips = [ + row.client_address + for row in rows + if row.action == 'REJECT' + and row.reason.startswith('Server IP address has bad reputation') + ] + + if _rejected_ips: + _qr = wblist_senderscore.filter_whitelisted_ips(ips=_rejected_ips) + if _qr[0]: + whitelisted_ips = _qr[1] + except: + pass + + return whitelisted_ips + + +def _filter_whitelisted_greylisting_ips(rows=None): + # Get IP addresses of rejected sessions due to greylisting. + whitelisted_ips = [] + if not rows: + return whitelisted_ips + + try: + _rejected_ips = [ + row.client_address + for row in rows + if row.action == '451' + and row.reason == '4.7.1 Intentional policy rejection, please try again later' + ] + + if _rejected_ips: + _qr = lib_greylist.filter_whitelisted_ips(ips=_rejected_ips) + if _qr[0]: + whitelisted_ips = _qr[1] + except: + pass + + return whitelisted_ips + + +class SMTPSessions: + @decorators.require_admin_login + def GET(self, page=1, outbound_only=False, rejected_only=False): + """Display log of SMTP rejections.""" + page = int(page) + if page < 1: + page = 1 + + qr = iredapd_log.get_log_smtp_sessions( + outbound_only=outbound_only, + rejected_only=rejected_only, + offset=settings.PAGE_SIZE_LIMIT * (page - 1), + limit=settings.PAGE_SIZE_LIMIT, + ) + + total = qr['total'] + rows = qr['rows'] + + if outbound_only: + tmpl = 'smtp_outbound_sessions.html' + else: + tmpl = 'smtp_sessions.html' + + num_insecure_outbound = 0 + insecure_outbound_usernames = [] + query_insecure_outbound_hours = settings.IREDAPD_QUERY_INSECURE_OUTBOUND_IN_HOURS + if outbound_only: + # Count insecure outbound connections. + _qr = iredapd_log.get_smtp_insecure_outbound(hours=query_insecure_outbound_hours) + if _qr[0]: + num_insecure_outbound = _qr[1]['total'] + insecure_outbound_usernames = _qr[1]['usernames'] + + # Get IP addresses of rejected sessions due to senderscore. + whitelisted_senderscore_ips = [] + if session.get('is_global_admin') and total > 0: + whitelisted_senderscore_ips = _filter_whitelisted_senderscore_ips(rows=rows) + + # Get IP addresses of rejected sessions due to greylisting. + whitelisted_greylisting_ips = [] + if session.get('is_global_admin') and total > 0: + whitelisted_greylisting_ips = _filter_whitelisted_greylisting_ips(rows=rows) + + return web.render('iredapd/activities/' + tmpl, + total=total, + rows=rows, + current_page=page, + rejected_only=rejected_only, + whitelisted_senderscore_ips=whitelisted_senderscore_ips, + whitelisted_greylisting_ips=whitelisted_greylisting_ips, + query_insecure_outbound_hours=query_insecure_outbound_hours, + num_insecure_outbound=num_insecure_outbound, + insecure_outbound_usernames=insecure_outbound_usernames, + msg=web.input().get('msg')) + + +class SMTPSessionsPerAccount: + @decorators.require_admin_login + def GET(self, account_type, account, page=1, outbound_only=False): + """Display log of SMTP authentications.""" + account_type = account_type.lower() + account = account.lower() + page = int(page) + + if page < 1: + page = 1 + + domains = [] + sasl_usernames = [] + senders = [] + recipients = [] + client_addresses = [] + encryption_protocols = [] + + # Make sure admin has privilege to manage this domain. + if account_type == 'sasl_username': + sasl_usernames = [account] + elif account_type == 'sender': + senders = [account] + elif account_type == 'recipient': + recipients = [account] + elif account_type == 'domain': + domains = [account] + elif account_type == 'client_address': + client_addresses = [account] + elif account_type == 'encryption_protocol': + encryption_protocols = [account] + + qr = iredapd_log.get_log_smtp_sessions( + domains=domains, + sasl_usernames=sasl_usernames, + senders=senders, + recipients=recipients, + encryption_protocols=encryption_protocols, + client_addresses=client_addresses, + outbound_only=outbound_only, + offset=settings.PAGE_SIZE_LIMIT * (page - 1), + limit=settings.PAGE_SIZE_LIMIT, + ) + total = qr['total'] or 0 + rows = qr['rows'] + + if outbound_only: + tmpl = 'smtp_outbound_sessions.html' + else: + tmpl = 'smtp_sessions.html' + + # Get IP addresses of rejected sessions due to senderscore. + whitelisted_senderscore_ips = [] + if session.get('is_global_admin') and total > 0: + whitelisted_senderscore_ips = _filter_whitelisted_senderscore_ips(rows=rows) + + # Get IP addresses of rejected sessions due to greylisting. + whitelisted_greylisting_ips = [] + if session.get('is_global_admin') and total > 0: + whitelisted_greylisting_ips = _filter_whitelisted_greylisting_ips(rows=rows) + + return web.render( + 'iredapd/activities/' + tmpl, + account_type=account_type, + account=account, + total=total, + rows=rows, + whitelisted_senderscore_ips=whitelisted_senderscore_ips, + whitelisted_greylisting_ips=whitelisted_greylisting_ips, + current_page=page, + msg=web.input().get('msg'), + ) + + +class SMTPSessionsRejected: + @decorators.require_admin_login + def GET(self, page=1): + c = SMTPSessions() + return c.GET(page=page, rejected_only=True) + + +class SMTPSessionsOutbound: + @decorators.require_admin_login + def GET(self, page=1): + c = SMTPSessions() + return c.GET(page=page, outbound_only=True) + + +class SMTPSessionsOutboundPerAccount: + @decorators.require_admin_login + def GET(self, account_type, account, page=1): + c = SMTPSessionsPerAccount() + return c.GET(account_type=account_type, + account=account, + page=page, + outbound_only=True) diff --git a/controllers/iredapd/senderscore.py b/controllers/iredapd/senderscore.py new file mode 100644 index 0000000..5921e5f --- /dev/null +++ b/controllers/iredapd/senderscore.py @@ -0,0 +1,14 @@ +from controllers import decorators +from controllers.utils import api_render +from libs.iredapd import wblist_senderscore + + +class WhitelistIPForSenderScore: + @decorators.require_global_admin + def PUT(self, ip): + """Whitelist given IP address for senderscore. + + curl -X PUT -i -b cookie.txt -d "ip=x.x.x.x" https:///api/wblist/senderscore/whitelist/ + """ + qr = wblist_senderscore.whitelist_ips(ips=[ip]) + return api_render(qr) diff --git a/controllers/iredapd/throttle.py b/controllers/iredapd/throttle.py new file mode 100644 index 0000000..d1d3097 --- /dev/null +++ b/controllers/iredapd/throttle.py @@ -0,0 +1,45 @@ +# Author: Zhang Huangbin + +import web +import settings + +from libs import form_utils +from libs.iredapd import throttle as iredapd_throttle + + +if settings.backend == 'ldap': + from libs.ldaplib import decorators +else: + from libs.sqllib import decorators + + +# server-wide throttle setting. +class GlobalThrottle: + @decorators.require_global_admin + def GET(self): + inbound_setting = iredapd_throttle.get_throttle_setting(account='@.', inout_type='inbound') + outbound_setting = iredapd_throttle.get_throttle_setting(account='@.', inout_type='outbound') + + return web.render('iredapd/throttle_global.html', + inbound_setting=inbound_setting, + outbound_setting=outbound_setting, + msg=web.input().get('msg')) + + @decorators.require_global_admin + def POST(self): + form = web.input(_unicode=False) + + t_account = '@.' + + inbound_setting = form_utils.get_throttle_setting(form, account=t_account, inout_type='inbound') + outbound_setting = form_utils.get_throttle_setting(form, account=t_account, inout_type='outbound') + + iredapd_throttle.add_throttle(account=t_account, + setting=inbound_setting, + inout_type='inbound') + + iredapd_throttle.add_throttle(account=t_account, + setting=outbound_setting, + inout_type='outbound') + + raise web.seeother('/system/throttle?msg=UPDATED') diff --git a/controllers/iredapd/urls.py b/controllers/iredapd/urls.py new file mode 100644 index 0000000..85a9b11 --- /dev/null +++ b/controllers/iredapd/urls.py @@ -0,0 +1,69 @@ +# Author: Zhang Huangbin + +import settings +from libs.regxes import email as e, domain as d, ip + +# fmt: off +urls = [ + # Throttling + '/system/throttle', 'controllers.iredapd.throttle.GlobalThrottle', + # Greylisting + '/system/greylisting', 'controllers.iredapd.greylist.DefaultGreylisting', + # Greylisting tracking data + '/system/greylisting/tracking/domain/(%s)' % d, 'controllers.iredapd.greylist.GreylistingRawTrackingData', + # White/blacklist based on rDNS + '/system/wblist/rdns', 'controllers.iredapd.wblist_rdns.WBListRDNS', + + # + # Activities + # + '/activities/smtp/sessions', 'controllers.iredapd.log.SMTPSessions', + r'/activities/smtp/sessions/page/(\d+)', 'controllers.iredapd.log.SMTPSessions', + '/activities/smtp/sessions/(sasl_username|sender|recipient)/(%s)' % e, 'controllers.iredapd.log.SMTPSessionsPerAccount', + r'/activities/smtp/sessions/(sasl_username|sender|recipient)/(%s)/page/(\d+)' % e, 'controllers.iredapd.log.SMTPSessionsPerAccount', + '/activities/smtp/sessions/(domain)/(%s)' % d, 'controllers.iredapd.log.SMTPSessionsPerAccount', + r'/activities/smtp/sessions/(domain)/(%s)/page/(\d+)' % d, 'controllers.iredapd.log.SMTPSessionsPerAccount', + '/activities/smtp/sessions/(client_address)/(%s)' % ip, 'controllers.iredapd.log.SMTPSessionsPerAccount', + r'/activities/smtp/sessions/(client_address)/(%s)/page/(\d+)' % ip, 'controllers.iredapd.log.SMTPSessionsPerAccount', + r'/activities/smtp/sessions/(encryption_protocol)/([0-9a-zA-Z\.]+)', 'controllers.iredapd.log.SMTPSessionsPerAccount', + r'/activities/smtp/sessions/(encryption_protocol)/([0-9a-zA-Z\.]+)/page/(\d+)', 'controllers.iredapd.log.SMTPSessionsPerAccount', + + '/activities/smtp/sessions/rejected', 'controllers.iredapd.log.SMTPSessionsRejected', + r'/activities/smtp/sessions/rejected/page/(\d+)', 'controllers.iredapd.log.SMTPSessionsRejected', + + # SMTP Authentications + '/activities/smtp/sessions/outbound', 'controllers.iredapd.log.SMTPSessionsOutbound', + r'/activities/smtp/sessions/outbound/page/(\d+)', 'controllers.iredapd.log.SMTPSessionsOutbound', + '/activities/smtp/sessions/outbound/(sasl_username|sender|recipient)/(%s)' % e, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + r'/activities/smtp/sessions/outbound/(sasl_username|sender|recipient)/(%s)/page/(\d+)' % e, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + '/activities/smtp/sessions/outbound/(domain)/(%s)' % d, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + r'/activities/smtp/sessions/outbound/(domain)/(%s)/page/(\d+)' % d, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + '/activities/smtp/sessions/outbound/(client_address)/(%s)' % ip, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + r'/activities/smtp/sessions/outbound/(client_address)/(%s)/page/(\d+)' % ip, 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + r'/activities/smtp/sessions/outbound/(encryption_protocol)/([0-9a-zA-Z\.]+)', 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + r'/activities/smtp/sessions/outbound/(encryption_protocol)/([0-9a-zA-Z\.]+)/page/(\d+)', 'controllers.iredapd.log.SMTPSessionsOutboundPerAccount', + + # API interfaces used by web ui. + '/api/wblist/senderscore/whitelist/(%s)$' % ip, 'controllers.iredapd.senderscore.WhitelistIPForSenderScore', + '/api/greylisting/global/whitelist/(%s)$' % ip, 'controllers.iredapd.api_greylist.APIGlobalWhitelist', +] + +# API Interfaces +if settings.ENABLE_RESTFUL_API: + urls += [ + # Throttling + '/api/throttle/global/(inbound|outbound)', 'controllers.iredapd.api_throttle.APIGlobalThrottle', + '/api/throttle/(%s)/(inbound|outbound)' % d, 'controllers.iredapd.api_throttle.APIDomainThrottle', + '/api/throttle/(%s)/(inbound|outbound)' % e, 'controllers.iredapd.api_throttle.APIUserThrottle', + + # Greylisting + '/api/greylisting/all', 'controllers.iredapd.api_greylist.APIAllSettings', + '/api/greylisting/global', 'controllers.iredapd.api_greylist.APIGlobalSetting', + '/api/greylisting/(%s)' % d, 'controllers.iredapd.api_greylist.APIDomainSetting', + '/api/greylisting/(%s)' % e, 'controllers.iredapd.api_greylist.APIUserSetting', + '/api/greylisting/global/whitelists', 'controllers.iredapd.api_greylist.APIGlobalWhitelists', + '/api/greylisting/(%s)/whitelists' % d, 'controllers.iredapd.api_greylist.APIDomainWhitelists', + '/api/greylisting/(%s)/whitelists' % e, 'controllers.iredapd.api_greylist.APIUserWhitelists', + '/api/greylisting/whitelist_spf_domains', 'controllers.iredapd.api_greylist.APIWhitelistSPFDomain', + ] +# fmt: on diff --git a/controllers/iredapd/wblist_rdns.py b/controllers/iredapd/wblist_rdns.py new file mode 100644 index 0000000..42d3524 --- /dev/null +++ b/controllers/iredapd/wblist_rdns.py @@ -0,0 +1,79 @@ +import web +from controllers import decorators + +from libs.iredutils import is_valid_wblist_rdns_domain +from libs.iredapd import wblist_rdns, wblist_senderscore + + +class WBListRDNS: + @decorators.require_global_admin + def GET(self): + # Get wblist records + (_status, _result) = wblist_rdns.get_wblist_rdns() + if not _status: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + whitelists = _result['whitelists'] + blacklists = _result['blacklists'] + + return web.render('iredapd/wblist/rdns.html', + whitelists=whitelists, + blacklists=blacklists, + msg=web.input().get('msg')) + + @decorators.require_global_admin + def POST(self): + form = web.input() + + whitelists = [str(i).lower() + for i in form.get('whitelists', '').splitlines() + if is_valid_wblist_rdns_domain(i)] + whitelists = list(set(whitelists)) + + blacklists = [str(i).lower() + for i in form.get('blacklists', '').splitlines() + if is_valid_wblist_rdns_domain(i)] + blacklists = list(set(blacklists)) + + (_status, _result) = wblist_rdns.reset_wblist_rdns(whitelists=whitelists, blacklists=blacklists) + if _status: + raise web.seeother('/system/wblist/rdns?msg=UPDATED') + else: + raise web.seeother('/system/wblist/rdns?msg=%s' % web.urlquote(_result)) + + +class WBListSenderScore: + @decorators.require_global_admin + def GET(self): + # Get wblist records + (_status, _result) = wblist_senderscore.get_whitelists() + if not _status: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + total = _result['total'] + ips = _result['ips'] + + return web.render('iredapd/wblist/senderscore.html', + total=total, + ips=ips, + msg=web.input().get('msg')) + + @decorators.require_global_admin + def POST(self): + form = web.input() + + whitelists = [str(i).lower() + for i in form.get('whitelists', '').splitlines() + if is_valid_wblist_rdns_domain(i)] + whitelists = list(set(whitelists)) + + blacklists = [str(i).lower() + for i in form.get('blacklists', '').splitlines() + if is_valid_wblist_rdns_domain(i)] + blacklists = list(set(blacklists)) + + (_status, _result) = wblist_rdns.reset_wblist_rdns(whitelists=whitelists, blacklists=blacklists) + if _status: + raise web.seeother('/system/wblist/senderscore?msg=UPDATED') + else: + raise web.seeother('/system/wblist/senderscore?msg=%s' % web.urlquote(_result)) diff --git a/controllers/mlmmj/__init__.py b/controllers/mlmmj/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/mlmmj/newsletter.py b/controllers/mlmmj/newsletter.py new file mode 100644 index 0000000..e0bbf72 --- /dev/null +++ b/controllers/mlmmj/newsletter.py @@ -0,0 +1,387 @@ +# Author: Zhang Huangbin + +import time +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import Header +import web + +from libs import iredutils +from libs.logger import logger +from libs.mlmmj import add_subscribers, remove_subscribers +import settings + +if settings.backend == 'ldap': + from libs.ldaplib.ml import get_profile_by_mlid +else: + from libs.sqllib.ml import get_profile_by_mlid + + +base_url = web.ctx.homedomain + settings.NEWSLETTER_BASE_URL + + +class Error: + """Display error messages happened during subscription/unsubscription.""" + def GET(self): + form = web.input(_unicode=False) + msg = form.get('msg') + return web.render('mlmmj/errors.html', msg=msg) + + +# SubUnsubSSR returns HTML snippet to requester directly. +class SubUnsubSSR: + def OPTIONS(self, action, mlid): + # These headers are used when HTTP POST requests are sent from web page + # running on another domain. + web.header("Access-Control-Allow-Origin", "*") + web.header("Access-Control-Allow-Headers", "*") + web.header("Access-Control-Allow-Methods", "POST") + return "" + + def POST(self, action, mlid): + web.header("Access-Control-Allow-Origin", "*") + + if action not in ['subscribe']: + return "INVALID_ACTION" + + form = web.input(_unicode=False) + subscriber = form.get('subscriber', '').lower() + + if not iredutils.is_email(subscriber): + return "Invalid email address." + + # Get newsletter profile + qr = get_profile_by_mlid(mlid=mlid) + if not qr[0]: + return "Invalid newsletter." + + profile = qr[1] + if settings.backend == 'ldap': + mail = profile['mail'][0] + name = profile.get('cn', [''])[0] + else: + mail = profile['address'] + name = profile['name'] + + # Generate an unique string as verification token + token = iredutils.generate_random_strings(length=32) + + # Set expire date for this subscription request + if action == 'subscribe': + _expire_hours = settings.NEWSLETTER_SUBSCRIPTION_REQUEST_EXPIRE_HOURS + else: + _expire_hours = settings.NEWSLETTER_UNSUBSCRIPTION_REQUEST_EXPIRE_HOURS + + expire_date = int(time.time()) + (int(_expire_hours) * 60 * 60) + + # + # Store this subscription request in sql db. + # + try: + # Delete existing subscription confirm. + web.conn_iredadmin.delete( + 'newsletter_subunsub_confirms', + vars={ + 'mlid': mlid, + 'subscriber': subscriber, + 'kind': action, + }, + where='mlid=$mlid AND subscriber=$subscriber AND kind=$kind', + ) + + # Insert a new record + web.conn_iredadmin.insert( + 'newsletter_subunsub_confirms', + mail=mail, + mlid=mlid, + subscriber=subscriber, + kind=action, + token=token, + expired=expire_date, + ) + + except Exception as e: + logger.error(e) + return "Internal server error, please try again later." + + # + # Send confirm email + # + # Generate mail message + _msg = MIMEMultipart('alternative') + + # Set mailing list address as sender in `From:` + _smtp_sender = mail + _smtp_sender_name = settings.NOTIFICATION_SENDER_NAME + if _smtp_sender_name: + _msg['From'] = '{} <{}>'.format(Header(_smtp_sender_name, 'utf-8'), _smtp_sender) + else: + _msg['From'] = _smtp_sender + + _msg['To'] = subscriber + + if action == 'subscribe': + _msg_subject = 'Subscription confirm' + _subunsub_url = base_url + '/subconfirm/{}/{}'.format(mlid, token) + else: + _msg_subject = 'Unsubscription confirm' + _subunsub_url = base_url + '/unsubconfirm/{}/{}'.format(mlid, token) + + # Add mailing list name. + if name: + _msg_subject += ': ' + name + + _msg['Subject'] = Header(_msg_subject, 'utf-8') + + if action == 'subscribe': + _msg_body = 'Please click link below to confirm subscription to newsletter' + else: + _msg_body = 'Please click link below to confirm unsubscription from newsletter' + + if name: + _msg_body += ' "' + name + '"' + + _msg_body += ':\n' + _subunsub_url + '\n' + _msg_body += '\nLink will expire in %d hours.' % settings.NEWSLETTER_SUBSCRIPTION_REQUEST_EXPIRE_HOURS + _msg_body += '\nIf this is not requested by you, please simply ignore this email.' + + _msg_body_plain = MIMEText(_msg_body, 'plain', 'utf-8') + _msg.attach(_msg_body_plain) + + _msg_string = _msg.as_string() + + qr = iredutils.sendmail( + recipients=subscriber, + message_text=_msg_string, + from_address=_smtp_sender, + ) + if qr[0]: + if action == 'subscribe': + return "Almost done, an email has been sent to the address, please click the link in email to confirm the subscription." + else: + return "Almost done, an email has been sent to the address, please click the link in email to unsubscribe." + else: + return qr[1] + + +class SubUnsub: + """Handle the subscription and unsubscription.""" + def GET(self, action, mlid): + if action not in ['subscribe', 'unsubscribe']: + raise web.seeother(base_url + '/error?msg=INVALID_ACTION', absolute=True) + + # Display a subscription form. + form = web.input(_unicode=False) + msg = form.get('msg') + + # Get newsletter profile + qr = get_profile_by_mlid(mlid=mlid) + if not qr[0]: + raise web.seeother(base_url + '/error?msg=INVALID_NEWSLETTER', absolute=True) + + profile = qr[1] + + # Get display name and description + if settings.backend == 'ldap': + name = profile.get('cn', [''])[0] + description = profile.get('description', [''])[0] + else: + name = profile['name'] + description = profile['description'] + + # Get basic newsletter info: display name, short introduction. + return web.render('mlmmj/subunsub.html', + action=action, + mlid=mlid, + name=name, + description=description, + msg=msg) + + def POST(self, action, mlid): + if action not in ['subscribe', 'unsubscribe']: + raise web.seeother(base_url + '/error?msg=INVALID_ACTION', absolute=True) + + form = web.input(_unicode=False) + subscriber = form.get('subscriber', '').lower() + + if not iredutils.is_email(subscriber): + raise web.seeother(base_url + '/error?msg=INVALID_SUBSCRIBER_EMAIL_ADDRESS', absolute=True) + + # Get newsletter profile + qr = get_profile_by_mlid(mlid=mlid) + if not qr[0]: + raise web.seeother(base_url + '/error?msg=INVALID_NEWSLETTER', absolute=True) + + profile = qr[1] + if settings.backend == 'ldap': + mail = profile['mail'][0] + name = profile.get('cn', [''])[0] + else: + mail = profile['address'] + name = profile['name'] + + # Generate an unique string as verification token + token = iredutils.generate_random_strings(length=32) + + # Set expire date for this subscription request + if action == 'subscribe': + _expire_hours = settings.NEWSLETTER_SUBSCRIPTION_REQUEST_EXPIRE_HOURS + else: + _expire_hours = settings.NEWSLETTER_UNSUBSCRIPTION_REQUEST_EXPIRE_HOURS + + expire_date = int(time.time()) + (int(_expire_hours) * 60 * 60) + + # + # Store this subscription request in sql db. + # + try: + # Delete existing subscription confirm. + web.conn_iredadmin.delete( + 'newsletter_subunsub_confirms', + vars={'mlid': mlid, 'subscriber': subscriber, 'kind': action}, + where='mlid=$mlid AND subscriber=$subscriber AND kind=$kind', + ) + + # Insert a new record + web.conn_iredadmin.insert( + 'newsletter_subunsub_confirms', + mail=mail, + mlid=mlid, + subscriber=subscriber, + kind=action, + token=token, + expired=expire_date, + ) + + except Exception as e: + logger.error(e) + raise web.seeother(base_url + '/error?msg=INTERNAL_SERVER_ERROR', absolute=True) + + # + # Send confirm email + # + # Generate mail message + _msg = MIMEMultipart('alternative') + + # Set mailing list address as sender in `From:` + _smtp_sender = mail + _smtp_sender_name = settings.NOTIFICATION_SENDER_NAME + if _smtp_sender_name: + _msg['From'] = '{} <{}>'.format(Header(_smtp_sender_name, 'utf-8'), _smtp_sender) + else: + _msg['From'] = _smtp_sender + + _msg['To'] = subscriber + + if action == 'subscribe': + _msg_subject = 'Subscription confirm' + _subunsub_url = base_url + '/subconfirm/{}/{}'.format(mlid, token) + else: + _msg_subject = 'Unsubscription confirm' + _subunsub_url = base_url + '/unsubconfirm/{}/{}'.format(mlid, token) + + # Add mailing list name. + if name: + _msg_subject += ': ' + name + + _msg['Subject'] = Header(_msg_subject, 'utf-8') + + if action == 'subscribe': + _msg_body = 'Please click link below to confirm subscription to newsletter' + else: + _msg_body = 'Please click link below to confirm unsubscription from newsletter' + + if name: + _msg_body += ' "' + name + '"' + + _msg_body += ':\n' + _subunsub_url + '\n' + _msg_body += '\nLink will expire in %d hours.' % settings.NEWSLETTER_SUBSCRIPTION_REQUEST_EXPIRE_HOURS + _msg_body += '\nIf this is not requested by you, please simply ignore this email.' + + _msg_body_plain = MIMEText(_msg_body, 'plain', 'utf-8') + _msg.attach(_msg_body_plain) + + _msg_string = _msg.as_string() + + qr = iredutils.sendmail( + recipients=subscriber, + message_text=_msg_string, + from_address=_smtp_sender, + ) + if qr[0]: + if action == 'subscribe': + raise web.seeother(base_url + '/subscribe/%s?msg=WAIT_FOR_SUBCONFIRM' % mlid, absolute=True) + else: + raise web.seeother(base_url + '/unsubscribe/%s?msg=WAIT_FOR_UNSUBCONFIRM' % mlid, absolute=True) + else: + raise web.seeother(base_url + '/error?msg=%s' % web.urlquote(qr[1]), absolute=True) + + +class SubUnsubConfirm: + """Process subscription confirm.""" + def GET(self, action, mlid, token): + if action == 'subconfirm': + action = 'subscribe' + elif action == 'unsubconfirm': + action = 'unsubscribe' + else: + raise web.seeother(base_url + '/error?msg=INVALID_ACTION', absolute=True) + + if not iredutils.is_mlid(mlid): + raise web.seeother(base_url + '/error?msg=INVALID_NEWSLETTER', absolute=True) + + if not iredutils.is_ml_confirm_token(token): + raise web.seeother(base_url + '/error?msg=TOKEN_INVALID', absolute=True) + + _record = {} + + try: + now = int(time.time()) + + qr = web.conn_iredadmin.select( + 'newsletter_subunsub_confirms', + vars={'mlid': mlid, 'token': token, 'kind': action, 'now': now}, + what='mail, mlid, subscriber', + where='mlid=$mlid AND token=$token AND kind=$kind AND expired >= $now', + limit=1, + ) + + qr = list(qr) + if qr: + _record = qr[0] + except Exception as e: + raise web.seeother(base_url + '/error?msg=%s' % web.urlquote(repr(e)), absolute=True) + + if not _record: + raise web.seeother(base_url + '/error?msg=TOKEN_EXPIRED', absolute=True) + + _mail = str(_record['mail']).lower() + _subscriber = str(_record['subscriber']).lower() + + # Subscribe this subscriber + if action == 'subscribe': + qr = add_subscribers(mail=_mail, + subscribers=[_subscriber], + require_confirm=False) + else: + qr = remove_subscribers(mail=_mail, subscribers=[_subscriber]) + + if not qr[0]: + raise web.seeother(base_url + '/error?msg=%s' % web.urlquote(qr[1]), absolute=True) + + try: + # Update the record expire time, instead of deleting the record. + now = int(time.time()) + web.conn_iredadmin.update( + 'newsletter_subunsub_confirms', + vars={'mlid': mlid, 'token': token, 'kind': action}, + expired=now, + where='mlid=$mlid AND token=$token AND kind=$kind', + ) + except Exception as e: + raise web.seeother(base_url + '/error?msg=%s' % web.urlquote(repr(e)), absolute=True) + + if action == 'subscribe': + raise web.seeother(base_url + '/subscribe/%s?msg=SUBSCRIBED' % mlid, absolute=True) + else: + raise web.seeother(base_url + '/unsubscribe/%s?msg=UNSUBSCRIBED' % mlid, absolute=True) diff --git a/controllers/mlmmj/urls.py b/controllers/mlmmj/urls.py new file mode 100644 index 0000000..18ecd6a --- /dev/null +++ b/controllers/mlmmj/urls.py @@ -0,0 +1,13 @@ +# Author: Zhang Huangbin +from libs.regxes import mailing_list_id as mlid +from libs.regxes import mailing_list_confirm_token as confirm_token + +# fmt: off +urls = [ + '/newsletter/noninteractive/(subscribe)/(%s)$' % mlid, 'controllers.mlmmj.newsletter.SubUnsubSSR', + '/newsletter/(subscribe|unsubscribe)/(%s)$' % mlid, 'controllers.mlmmj.newsletter.SubUnsub', + '/newsletter/(subconfirm|unsubconfirm)/({})/({})$'.format(mlid, confirm_token), 'controllers.mlmmj.newsletter.SubUnsubConfirm', + # Handle error messages + '/newsletter/error', 'controllers.mlmmj.newsletter.Error', +] +# fmt: on diff --git a/controllers/panel/__init__.py b/controllers/panel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/panel/domain_ownership.py b/controllers/panel/domain_ownership.py new file mode 100644 index 0000000..405790d --- /dev/null +++ b/controllers/panel/domain_ownership.py @@ -0,0 +1,69 @@ +# Author: Zhang Huangbin + +import web +import settings +from controllers import decorators + +from libs import iredutils +from libs.panel.domain_ownership import get_pending_domains, verify_domain_ownership + +if settings.backend == 'ldap': + from libs.ldaplib.domain import update_ownership_verified_domain + from libs.ldaplib.domain import enable_domain_without_ownership_verification +else: + from libs.sqllib.domain import update_ownership_verified_domain + from libs.sqllib.domain import enable_domain_without_ownership_verification + +session = web.config.get('_session', {}) + + +class VerifyOwnership: + @decorators.require_admin_login + def GET(self): + qr = get_pending_domains() + if qr[0]: + ownership_verify_codes = qr[1] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + return web.render('panel/domain_ownership.html', + ownership_verify_codes=ownership_verify_codes, + msg=web.input().get('msg', '')) + + @decorators.require_admin_login + def POST(self): + form = web.input(domain=[]) + + if 'verify' in form: + action = 'verify' + elif 'enable_without_verification' in form: + action = 'enable_without_verification' + else: + raise web.seeother('/verify/domain_ownership?msg=INVALID_ACTION') + + domains = form.get('domain', []) + domains = [str(d).lower() for d in domains if iredutils.is_domain(d)] + + if action == 'verify': + _qr = verify_domain_ownership(domains=domains) + if _qr[0]: + verified_domains = _qr[1] + for (pd, ad) in verified_domains: + qr = update_ownership_verified_domain(primary_domain=pd, + alias_domain=ad) + if not qr[0]: + raise web.seeother('/verify/domain_ownership?msg=%s' % web.urlquote(qr[1])) + + raise web.seeother('/verify/domain_ownership') + else: + raise web.seeother('/verify/domain_ownership?msg=%s' % web.urlquote(_qr[1])) + elif action == 'enable_without_verification': + # Enable domains, and mark them as verified + if not session.get('is_global_admin'): + raise web.seeother('/verify/domain_ownership?msg=PERMISSION_DENIED') + + qr = enable_domain_without_ownership_verification(domains=domains) + if not qr[0]: + raise web.seeother('/verify/domain_ownership?msg=%s' % web.urlquote(qr[1])) + + raise web.seeother('/verify/domain_ownership') diff --git a/controllers/panel/log.py b/controllers/panel/log.py new file mode 100644 index 0000000..22fddc8 --- /dev/null +++ b/controllers/panel/log.py @@ -0,0 +1,180 @@ +# Author: Zhang Huangbin + +import web +import settings +from libs import __url_license_terms__ +from libs import sysinfo +from controllers import decorators +from libs.panel import LOG_EVENTS, log as loglib + +session = web.config.get('_session') + +if settings.backend == 'ldap': + from libs.ldaplib.core import LDAPWrap + from libs.ldaplib import admin as ldap_lib_admin + from libs import __version_ldap__ as __version__ +elif settings.backend in ['mysql', 'pgsql']: + from libs import __version_sql__ as __version__ + from libs.sqllib import SQLWrap, admin as sql_lib_admin + + +class Log: + @decorators.require_admin_login + def GET(self): + form = web.input(_unicode=False) + + # Get queries. + form_event = web.safestr(form.get('event', 'all')) + form_domain = web.safestr(form.get('domain', 'all')) + form_admin = web.safestr(form.get('admin', 'all')) + form_cur_page = web.safestr(form.get('page', '1')) + + if not form_cur_page.isdigit() or form_cur_page == '0': + form_cur_page = 1 + else: + form_cur_page = int(form_cur_page) + + total, entries = loglib.list_logs(event=form_event, + domain=form_domain, + admin=form_admin, + cur_page=form_cur_page) + + # Pre-defined + all_domain_names = [] + all_admin_emails = [] + + if settings.backend == 'ldap': + _wrap = LDAPWrap() + conn = _wrap.conn + + # Get all managed domains under control. + qr = ldap_lib_admin.get_managed_domains( + admin=session.get('username'), + domain_name_only=True, + conn=conn, + ) + if qr[0]: + all_domain_names = qr[1] + + # Get all admins. + if session.get('is_global_admin'): + result = ldap_lib_admin.list_accounts(attributes=['mail'], conn=conn) + if result[0] is not False: + all_admin_emails = [v[1]['mail'][0] for v in result[1]] + else: + all_admin_emails = [form_admin] + + elif settings.backend in ['mysql', 'pgsql']: + # Get all managed domains under control. + _wrap = SQLWrap() + conn = _wrap.conn + qr = sql_lib_admin.get_managed_domains( + admin=session.get('username'), + domain_name_only=True, + conn=conn, + ) + if qr[0]: + all_domain_names = qr[1] + + # Get all admins. + if session.get('is_global_admin'): + qr = sql_lib_admin.get_all_admins(columns=['username'], email_only=True, conn=conn) + if qr[0]: + all_admin_emails = qr[1] + else: + all_admin_emails = [form_admin] + + all_domain_names.sort() + all_admin_emails.sort() + + return web.render('panel/log.html', + event=form_event, + domain=form_domain, + admin=form_admin, + log_events=LOG_EVENTS, + cur_page=form_cur_page, + total=total, + entries=entries, + all_domain_names=all_domain_names, + all_admin_emails=all_admin_emails, + msg=form.get('msg')) + + @decorators.require_global_admin + @decorators.csrf_protected + @decorators.require_admin_login + def POST(self): + form = web.input(_unicode=False, id=[]) + action = form.get('action', 'delete') + + delete_all = False + if action == 'deleteAll': + delete_all = True + + qr = loglib.delete_logs(form=form, delete_all=delete_all) + if qr[0]: + # Keep the log filter. + form_domain = web.safestr(form.get('domain')) + form_admin = web.safestr(form.get('admin')) + form_event = web.safestr(form.get('event')) + url = 'domain={}&admin={}&event={}'.format(form_domain, form_admin, form_event) + + raise web.seeother('/activities/admins?%s&msg=DELETED' % url) + else: + raise web.seeother('/activities/admins?msg=%s' % web.urlquote(qr[1])) + + +class License: + @decorators.require_global_admin + def GET(self): + qr_info = sysinfo.get_license_info() + + if qr_info[0]: + latest_ver = qr_info[1].get('latestversion', '1.0') + + has_update = False + try: + # Convert version number to major + minor numbers, then + # convert to integer and compare. + # + # Warning: Comparing (float) numbers in string format is not + # accurate. For example, version "4.10" is "older" than "4.9". + latest_vers = latest_ver.split(".", 1) + if len(latest_vers) == 2: + latest_major = latest_vers[0] + latest_minor = latest_vers[1] + else: + latest_major = latest_ver + latest_minor = "0" + + cur_vers = __version__.split(".", 1) + if len(cur_vers) == 2: + cur_major = cur_vers[0] + cur_minor = cur_vers[1] + else: + cur_major = __version__ + cur_minor = "0" + + # Convert to int. + i_latest_major = int(latest_major) + i_latest_minor = int(latest_minor) + i_cur_major = int(cur_major) + i_cur_minor = int(cur_minor) + + if i_latest_major > i_cur_major: + has_update = True + + if (i_latest_major == i_cur_major) and (i_latest_minor > i_cur_minor): + has_update = True + + if has_update: + session['new_version_available'] = True + session['new_version'] = latest_ver + except: + pass + + return web.render('panel/license.html', + info=qr_info[1], + url_license_terms=__url_license_terms__, + version=__version__) + else: + return web.render('panel/license.html', error=qr_info[1]) diff --git a/controllers/panel/sys_settings.py b/controllers/panel/sys_settings.py new file mode 100644 index 0000000..cff4f73 --- /dev/null +++ b/controllers/panel/sys_settings.py @@ -0,0 +1,36 @@ +# Author: Zhang Huangbin + +import web +from controllers import decorators +from libs import iredutils, form_utils + + +class Settings: + @decorators.require_global_admin + def GET(self): + db_settings = iredutils.get_settings_from_db(account='global') + return web.render('panel/settings.html', + db_settings=db_settings) + + @decorators.require_global_admin + @decorators.csrf_protected + def POST(self): + form = web.input() + + # Re-format value of some parameters, then replace the value in `form`. + # input: textarea + for k in ['global_admin_ip_list', + 'admin_login_ip_list', + 'restful_api_clients']: + _list = form_utils.get_multi_values(form=form, + input_name=k, + input_is_textarea=True, + is_ip_or_network=True) + + form[k] = _list + + qr = iredutils.store_settings_in_db(kvs=form, flush=True) + if qr[0]: + return web.seeother('/system/settings?msg=UPDATED') + else: + return web.seeother('/system/settings?msg=' + web.urlquote(qr[1])) diff --git a/controllers/panel/urls.py b/controllers/panel/urls.py new file mode 100644 index 0000000..0da6ff6 --- /dev/null +++ b/controllers/panel/urls.py @@ -0,0 +1,12 @@ +# Author: Zhang Huangbin + +# fmt: off +urls = [ + '/expired', 'controllers.utils.Expired', + '/system', 'controllers.panel.log.Log', + '/system/settings', 'controllers.panel.sys_settings.Settings', + '/system/license', 'controllers.panel.log.License', + '/activities/admins', 'controllers.panel.log.Log', + '/verify/domain_ownership', 'controllers.panel.domain_ownership.VerifyOwnership', +] +# fmt: on diff --git a/controllers/sql/__init__.py b/controllers/sql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/controllers/sql/admin.py b/controllers/sql/admin.py new file mode 100644 index 0000000..060abab --- /dev/null +++ b/controllers/sql/admin.py @@ -0,0 +1,208 @@ +# Author: Zhang Huangbin + +import web +import settings +from libs import iredutils +from libs.l10n import TIMEZONES + +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import general as sql_lib_general +from libs.sqllib import user as sql_lib_user +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import utils as sql_lib_utils + +session = web.config.get('_session') + + +class List: + @decorators.require_global_admin + def GET(self, cur_page=1): + form = web.input() + cur_page = int(cur_page) + + if cur_page == 0: + cur_page = 1 + + _wrap = SQLWrap() + conn = _wrap.conn + + result = sql_lib_admin.get_paged_admins(conn=conn, + cur_page=cur_page) + + if result[0]: + (total, records) = (result[1]['total'], result[1]['records']) + + # Get list of global admins. + all_global_admins = [] + qr = sql_lib_admin.get_all_global_admins(conn=conn) + if qr[0]: + all_global_admins = qr[1] + + return web.render( + 'sql/admin/list.html', + cur_page=cur_page, + total=total, + admins=records, + allGlobalAdmins=all_global_admins, + msg=form.get('msg', None), + ) + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(result[1])) + + @decorators.require_global_admin + @decorators.csrf_protected + def POST(self): + form = web.input(_unicode=False, mail=[]) + + accounts = form.get('mail', []) + action = form.get('action', None) + msg = form.get('msg', None) + + _wrap = SQLWrap() + conn = _wrap.conn + + if action == 'delete': + result = sql_lib_admin.delete_admins(mails=accounts, + revoke_admin_privilege_from_user=True, + conn=conn) + msg = 'DELETED' + elif action == 'disable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='admin', + enable_account=False) + msg = 'DISABLED' + elif action == 'enable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='admin', + enable_account=True) + msg = 'ENABLED' + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + raise web.seeother('/admins?msg=%s' % msg) + else: + raise web.seeother('/admins?msg=?' + web.urlquote(result[1])) + + +class Profile: + @decorators.require_admin_login + def GET(self, profile_type, mail): + mail = str(mail).lower() + form = web.input() + + if not (session.get('is_global_admin') or session.get('username') == mail): + # Don't allow to view/update others' profile. + raise web.seeother('/profile/admin/general/%s?msg=PERMISSION_DENIED' % session.get('username')) + + _wrap = SQLWrap() + conn = _wrap.conn + + is_global_admin = sql_lib_general.is_global_admin(admin=mail, conn=conn) + result = sql_lib_admin.get_profile(mail=mail, conn=conn) + + if result[0]: + profile = result[1] + qr = sql_lib_general.get_admin_settings(admin=mail, conn=conn) + if qr[0]: + admin_settings = qr[1] + else: + return qr + + # Get all domains. + all_domains = [] + + qr_all_domains = sql_lib_domain.get_all_domains(conn=conn) + if qr_all_domains[0]: + all_domains = qr_all_domains[1] + + # Get managed domains. + managed_domains = [] + + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=mail, + domain_name_only=True, + listed_only=True) + if qr[0]: + managed_domains += qr[1] + + return web.render( + 'sql/admin/profile.html', + mail=mail, + profile_type=profile_type, + is_global_admin=is_global_admin, + profile=profile, + admin_settings=admin_settings, + languagemaps=iredutils.get_language_maps(), + timezones=TIMEZONES, + allDomains=all_domains, + managedDomains=managed_domains, + min_passwd_length=settings.min_passwd_length, + max_passwd_length=settings.max_passwd_length, + store_password_in_plain_text=settings.STORE_PASSWORD_IN_PLAIN_TEXT, + password_policies=iredutils.get_password_policies(), + msg=form.get('msg'), + ) + else: + # Return to user profile page if admin is a mail user. + qr = sql_lib_user.simple_profile(conn=conn, + mail=mail, + columns=['username']) + + if qr[0]: + raise web.seeother('/profile/user/general/' + mail) + else: + raise web.seeother('/admins?msg=' + web.urlquote(result[1])) + + @decorators.csrf_protected + @decorators.require_admin_login + def POST(self, profile_type, mail): + mail = str(mail).lower() + form = web.input(domainName=[]) + + if not (session.get('is_global_admin') or session.get('username') == mail): + # Don't allow to view/update others' profile. + raise web.seeother('/profile/admin/general/%s?msg=PERMISSION_DENIED' % session.get('username')) + + _wrap = SQLWrap() + conn = _wrap.conn + + result = sql_lib_admin.update(mail=mail, + profile_type=profile_type, + form=form, + conn=conn) + + if result[0]: + raise web.seeother('/profile/admin/{}/{}?msg=UPDATED'.format(profile_type, mail)) + else: + raise web.seeother('/profile/admin/{}/{}?msg={}'.format(profile_type, mail, web.urlquote(result[1]))) + + +class Create: + @decorators.require_global_admin + def GET(self): + form = web.input() + return web.render('sql/admin/create.html', + languagemaps=iredutils.get_language_maps(), + default_language=settings.default_language, + min_passwd_length=settings.min_passwd_length, + max_passwd_length=settings.max_passwd_length, + password_policies=iredutils.get_password_policies(), + msg=form.get('msg')) + + @decorators.require_global_admin + @decorators.csrf_protected + def POST(self): + form = web.input() + mail = web.safestr(form.get('mail')).lower() + + qr = sql_lib_admin.add_admin_from_form(form=form, conn=None) + + if qr[0]: + # Redirect to assign domains. + raise web.seeother('/profile/admin/general/%s?msg=CREATED' % mail) + else: + raise web.seeother('/create/admin?msg=' + web.urlquote(qr[1])) diff --git a/controllers/sql/alias.py b/controllers/sql/alias.py new file mode 100644 index 0000000..9ecf0d5 --- /dev/null +++ b/controllers/sql/alias.py @@ -0,0 +1,224 @@ +# Author: Zhang Huangbin + +import web + +from libs import iredutils, form_utils +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import alias as sql_lib_alias +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import general as sql_lib_general +from libs.sqllib import utils as sql_lib_utils + +session = web.config.get('_session') + + +class List: + @decorators.require_domain_access + def GET(self, domain, cur_page=1, disabled_only=False): + domain = str(domain).lower() + cur_page = int(cur_page) or 1 + + form = web.input(_unicode=False) + + all_first_chars = [] + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + total = sql_lib_alias.num_aliases_under_domain(conn=conn, + domain=domain, + disabled_only=disabled_only, + first_char=first_char) + + records = [] + if total: + _qr = sql_lib_general.get_first_char_of_all_accounts(domain=domain, + account_type='alias', + conn=conn) + if _qr[0]: + all_first_chars = _qr[1] + + qr = sql_lib_alias.get_basic_alias_profiles(conn=conn, + domain=domain, + page=cur_page, + first_char=first_char, + disabled_only=disabled_only) + if qr[0]: + records = qr[1] + + return web.render( + 'sql/alias/list.html', + cur_domain=domain, + cur_page=cur_page, + total=total, + aliases=records, + all_first_chars=all_first_chars, + first_char=first_char, + disabled_only=disabled_only, + msg=form.get('msg', None), + ) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, domain): + form = web.input(_unicode=False, mail=[]) + domain = str(domain).lower() + + accounts = form.get('mail', []) + action = form.get('action', None) + msg = form.get('msg', None) + + # Filter aliases not under the same domain. + accounts = [str(v).lower() + for v in accounts + if iredutils.is_email(v) and str(v).endswith('@' + domain)] + + _wrap = SQLWrap() + conn = _wrap.conn + + if action == 'delete': + result = sql_lib_alias.delete_aliases(conn=conn, + accounts=accounts) + msg = 'DELETED' + elif action == 'disable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='alias', + enable_account=False) + msg = 'DISABLED' + elif action == 'enable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='alias', + enable_account=True) + msg = 'ENABLED' + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + raise web.seeother('/aliases/{}?msg={}'.format(domain, msg)) + else: + raise web.seeother('/aliases/{}?msg={}'.format(domain, web.urlquote(result[1]))) + + +class ListDisabled: + @decorators.require_domain_access + def GET(self, domain, cur_page=1): + _list = List() + return _list.GET(domain=domain, cur_page=cur_page, disabled_only=True) + + +class Create: + @decorators.require_domain_access + def GET(self, domain): + domain = str(domain).lower() + + form = web.input() + all_domains = [] + + # Get all domains, select the first one. + _wrap = SQLWrap() + conn = _wrap.conn + + if session.get('is_global_admin'): + qr = sql_lib_domain.get_all_domains(conn=conn, name_only=True) + else: + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True) + + if qr[0]: + all_domains = qr[1] + + # Get domain profile. + qr_profile = sql_lib_domain.simple_profile(domain=domain, conn=conn) + if qr_profile[0]: + domain_profile = qr_profile[1] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr_profile[1])) + + # Cet total number and allocated quota size of existing users under domain. + num_aliases_under_domain = sql_lib_alias.num_aliases_under_domain(conn=conn, domain=domain) + + return web.render( + 'sql/alias/create.html', + cur_domain=domain, + allDomains=all_domains, + profile=domain_profile, + num_existing_aliases=num_aliases_under_domain, + msg=form.get('msg'), + ) + + @decorators.require_domain_access + @decorators.csrf_protected + def POST(self, domain): + domain = str(domain).lower() + form = web.input() + + domain_in_form = form_utils.get_domain_name(form) + + if domain != domain_in_form: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + listname = form_utils.get_single_value(form, input_name='listname', to_string=True) + mail = listname + '@' + domain + + result = sql_lib_alias.add_alias_from_form(domain=domain, form=form) + + if result[0]: + raise web.seeother('/profile/alias/general/%s?msg=CREATED' % mail) + else: + raise web.seeother('/create/alias/{}?msg={}'.format(domain, web.urlquote(result[1]))) + + +class Profile: + @decorators.require_domain_access + def GET(self, profile_type, mail): + if profile_type == 'rename': + raise web.seeother('/profile/alias/general/' + mail) + + form = web.input() + mail = web.safestr(mail).lower() + domain = mail.split('@', 1)[-1] + + if not iredutils.is_email(mail): + raise web.seeother('/domains?msg=INVALID_MAIL') + + qr = sql_lib_alias.get_profile(mail=mail, + with_members=True, + with_moderators=True, + conn=None) + if qr[0]: + profile = qr[1] + else: + raise web.seeother('/aliases/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + return web.render('sql/alias/profile.html', + cur_domain=domain, + mail=mail, + profile_type=profile_type, + profile=profile, + msg=form.get('msg')) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, profile_type, mail): + form = web.input() + + result = sql_lib_alias.update(mail=mail, + profile_type=profile_type, + form=form) + + if profile_type == 'rename': + profile_type = 'general' + + if result[0]: + raise web.seeother('/profile/alias/{}/{}?msg=UPDATED'.format(profile_type, mail)) + else: + raise web.seeother('/profile/alias/{}/{}?msg={}'.format(profile_type, mail, web.urlquote(result[1]))) diff --git a/controllers/sql/api_admin.py b/controllers/sql/api_admin.py new file mode 100644 index 0000000..6e18d88 --- /dev/null +++ b/controllers/sql/api_admin.py @@ -0,0 +1,158 @@ +import web + +from controllers.utils import api_render + +from libs.sqllib import SQLWrap +from libs.sqllib import decorators, api_utils +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import general as sql_lib_general + +import settings + + +# Parameter names used in API interface and web form, both POST and PUT. +_param_maps = [('maxDomains', 'create_max_domains'), + ('maxUsers', 'create_max_users'), + ('maxAliases', 'create_max_aliases'), + ('maxLists', 'create_max_lists'), + ('maxQuota', 'create_max_quota'), + ('quotaUnit', 'create_quota_unit')] + + +class APIAdmin: + @decorators.api_require_global_admin + def GET(self, mail): + """Get profile of a standalone domain admin. + + curl -X GET -i -b cookie.txt https:///api/admin/ + """ + mail = str(mail).lower() + + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_admin.get_profile(mail=mail, conn=conn) + if qr[0]: + profile = api_utils.export_sql_record(record=qr[1], + remove_columns=settings.API_HIDDEN_ADMIN_PROFILES) + + profile['isglobaladmin'] = 0 + if sql_lib_general.is_global_admin(admin=mail, conn=conn): + profile['isglobaladmin'] = 1 + + _qr = sql_lib_admin.get_managed_domains(admin=mail, + domain_name_only=True, + listed_only=True, + conn=conn) + if _qr[0]: + profile['managed_domains'] = _qr[1] + + return api_render((True, profile)) + else: + return api_render(qr) + + @decorators.api_require_global_admin + def POST(self, mail): + """Create a new domain. + + curl -X POST -i -b cookie.txt -d "var=&var2=value2" https:///api/admin/ + + :param mail: admin email address. + + Form parameters: + + `name`: the display name of this admin + `password`: admin's password + `accountStatus`: account status (active, disabled) + `domainGlobalAdmin`: Mark this admin as global admin (yes, no). + `language`: default preferred language for new user. + e.g. en_US for English, de_DE for Deutsch. + + Form parameters listed below are used by normal domain admin, so they + cannot be set while `domainGlobalAdmin=yes`. + + `maxDomains`: how many mail domains this admin can create. + `maxQuota`: how much mailbox quota this admin can create. + Quota is shared by all domains created/managed by this + admin. Sample: 10, 20, 30. Must be used with @quotaUnit. + `quotaUnit`: quota unit of @maxQuota. Must be used with @maxQuota. + `maxUsers`: how many mail users this admin can create. + It's shared by all domains created/managed by this admin. + `maxAliases`: how many mail aliases this admin can create. + It's shared by all domains created/managed by this admin. + `maxUsers`: how many mailing lists this admin can create. + It's shared by all domains created/managed by this admin. + """ + form = web.input() + + form['mail'] = mail + form['cn'] = form.get('name') + form['newpw'] = form.get('password') + form['confirmpw'] = form.get('password') + form['domainGlobalAdmin'] = form.get('isGlobalAdmin') + form['preferredLanguage'] = form.get('language') + + for (k_api, k_web) in _param_maps: + if k_api in form: + form[k_web] = form[k_api] + + # [(api_form_name, web_form_name), ...] + for (k_api, k_web) in [('disableViewingMailLog', 'disable_viewing_mail_log'), + ('disableManagingQuarantinedMails', 'disable_managing_quarantined_mails')]: + v = form.get(k_api, '') + if v == 'yes': + form[k_web] = 'yes' + + qr = sql_lib_admin.add_admin_from_form(form=form) + return api_render(qr) + + @decorators.api_require_global_admin + def DELETE(self, mail): + """Delete an existing mail domain. + + curl -X DELETE -i -b cookie.txt https:///api/admin/ + """ + qr = sql_lib_admin.delete_admins(mails=[mail], revoke_admin_privilege_from_user=False) + return api_render(qr) + + @decorators.api_require_global_admin + def PUT(self, mail): + """Update profile of existing standalone domain admin. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/domain/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///api/domain/ + + :param mail: full admin email address. + + Form parameters: + + `name`: the display name of this admin + `password`: admin's password + `accountStatus`: account status (active, disabled) + `domainGlobalAdmin`: Mark this admin as global admin (yes, no). + `language`: default preferred language for new user. + e.g. en_US for English, de_DE for Deutsch. + + Form parameters listed below are used by normal domain admin, so they + cannot be set while `domainGlobalAdmin=yes`. + + `maxDomains`: how many mail domains this admin can create. + `maxQuota`: how much mailbox quota this admin can create. + Quota is shared by all domains created/managed by this + admin. Sample: 10, 20, 30. Must be used with @quotaUnit. + `quotaUnit`: quota unit of @maxQuota. Must be used with @maxQuota. + `maxUsers`: how many mail users this admin can create. + It's shared by all domains created/managed by this admin. + `maxAliases`: how many mail aliases this admin can create. + It's shared by all domains created/managed by this admin. + `maxUsers`: how many mailing lists this admin can create. + It's shared by all domains created/managed by this admin. + """ + form = web.input() + + for (k_api, k_web) in _param_maps: + if k_api in form: + form[k_web] = form[k_api] + + qr = sql_lib_admin.api_update_profile(form=form, mail=mail) + return api_render(qr) diff --git a/controllers/sql/api_alias.py b/controllers/sql/api_alias.py new file mode 100644 index 0000000..166e4a0 --- /dev/null +++ b/controllers/sql/api_alias.py @@ -0,0 +1,243 @@ +# Author: Zhang Huangbin + +import web + +from controllers.utils import api_render + +from libs import iredutils, form_utils +from libs.logger import log_activity +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import general as sql_lib_general +from libs.sqllib import alias as sql_lib_alias +from libs.sqllib import api_utils + +session = web.config.get('_session') + + +class APIAlias: + @decorators.api_require_domain_access + def GET(self, mail): + """Export mail alias profile. + + curl -X GET -i -b cookie.txt https:///api/alias/ + """ + mail = str(mail).lower() + qr = sql_lib_alias.get_profile(mail=mail, conn=None) + if qr[0]: + profile = api_utils.export_sql_record(record=qr[1]) + return api_render((True, profile)) + else: + return api_render(qr) + + @decorators.api_require_domain_access + def POST(self, mail): + """Create a new mail alias account. + + curl -X POST -i -b cookie.txt -d "..." https:///api/alias/ + + Optional POST data: + + @name - display name + @accessPolicy - access policy + @members - members of mail alias + """ + mail = str(mail).lower() + (listname, domain) = mail.split('@', 1) + + form = web.input() + + form['listname'] = listname + form['domainName'] = domain + + form['cn'] = form.get('name') + + qr = sql_lib_alias.add_alias_from_form(domain=domain, form=form) + + if qr[0] and 'members' in form: + # Update mail forwarding addresses + _addresses = form_utils.get_multi_values_from_api(form=form, + input_name='members', + to_lowercase=False, + is_email=True) + _qr = sql_lib_alias.reset_members(mail=mail, members=_addresses) + return api_render(_qr) + + return api_render(qr) + + # Delete aliases. + @decorators.api_require_domain_access + def DELETE(self, mail): + """Delete a mail alias account. + curl -X DELETE -i -b cookie.txt https:///api/alias/ + """ + mail = str(mail).lower() + qr = sql_lib_alias.delete_aliases(accounts=[mail]) + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, mail): + """Update profile of existing mail alias account. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/alias/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///api/alias/ + + Optional PUT data: + + @name - common name (or, display name) + @accountStatus - enable or disable user. possible value is: active, disabled. + @accessPolicy - access policy. + @members - members of mail alias + @addMember - add new members to mailing list + @removeMember - remove members from mailing list + """ + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + + form = web.input() + + params = {} + + # Name + kv = form_utils.get_form_dict(form, + input_name='name', + key_name='name', + default_value='') + params.update(kv) + + # accountStatus + kv = form_utils.get_form_dict(form, + input_name='accountStatus', + key_name='active', + default_value='1') + params.update(kv) + + # Access policy + kv = form_utils.get_form_dict(form, + input_name='accessPolicy', + key_name='accesspolicy', + default_value='public') + params.update(kv) + + # Reset all members + _members = [] + + # Add new members + _new = [] + + # Remove existing members + _removed = [] + + if 'members' in form: + # Update mail forwarding addresses + _v = form_utils.get_multi_values_from_api(form=form, + input_name='members', + to_lowercase=False, + is_email=True) + _members = [iredutils.lower_email_with_upper_ext_address(i) for i in _v] + + else: + if 'addMember' in form: + _v = form_utils.get_multi_values_from_api(form=form, + input_name='addMember', + to_lowercase=False, + is_email=True) + _new = [iredutils.lower_email_with_upper_ext_address(i) for i in _v] + + if 'removeMember' in form: + _v = form_utils.get_multi_values_from_api(form=form, + input_name='removeMember', + to_lowercase=False, + is_email=True) + _removed = [iredutils.lower_email_with_upper_ext_address(i) for i in _v] + + if not (params or ('members' in form) or _new or _removed): + return api_render(True) + + _wrap = SQLWrap() + conn = _wrap.conn + + if not sql_lib_general.is_alias_exists(mail=mail, conn=conn): + return api_render((False, 'NO_SUCH_ACCOUNT')) + + if params: + try: + conn.update('alias', + vars={'mail': mail}, + where='address=$mail', + **params) + + log_activity(msg="Update alias profile: {} -> {}".format(mail, ', '.join(params)), + admin=session.get('username'), + username=mail, + domain=domain, + event='update') + + except Exception as e: + return api_render((False, repr(e))) + + if 'members' in form: + qr = sql_lib_alias.reset_members(mail=mail, members=_members, conn=conn) + + if not qr[0]: + return api_render(qr) + + if _new or _removed: + qr = sql_lib_alias.update_members(mail=mail, + new_members=_new, + removed_members=_removed, + conn=conn) + + if not qr[0]: + return api_render(qr) + + return api_render(True) + + +class APIChangeEmail: + @decorators.api_require_domain_access + def POST(self, mail, new_mail): + """Change email address of mail alias account. + + curl -X POST -i -b cookie.txt https:///api/alias//change_email/ + """ + mail = str(mail).lower() + new_mail = str(new_mail).lower() + + qr = sql_lib_alias.change_email(mail=mail, new_mail=new_mail) + return api_render(qr) + + +class APIAliases: + @decorators.api_require_domain_access + def GET(self, domain): + """List all mail aliases under given domain. + + curl -X GET -i -b cookie.txt https:///api/aliases/ + + Optional parameters: + + @email_only -- return a list of email addresses. + if not present, return a list of account profiles + (dicts). + @disabled_only -- return disabled accounts. + """ + domain = str(domain).lower() + + form = web.input(_unicode=True) + email_only = ('email_only' in form) + disabled_only = ('disabled_only' in form) + + qr = sql_lib_alias.get_basic_alias_profiles(domain=domain, + email_only=email_only, + disabled_only=disabled_only, + conn=None) + + if qr[0]: + if email_only: + emails = qr[1] + return api_render((True, emails)) + else: + profiles = api_utils.export_sql_records(records=qr[1]) + return api_render((True, profiles)) + else: + return api_render(qr) diff --git a/controllers/sql/api_domain.py b/controllers/sql/api_domain.py new file mode 100644 index 0000000..0c043ed --- /dev/null +++ b/controllers/sql/api_domain.py @@ -0,0 +1,237 @@ +# Author: Zhang Huangbin + +import web + +from controllers.utils import api_render + +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import general as sql_lib_general +from libs.sqllib import api_utils + +session = web.config.get('_session') + + +class APIDomains: + @decorators.api_require_admin_login + def GET(self): + """Get all managed domains. + + curl -X GET -i -b cookie.txt https:///api/domains + curl -X GET -i -b cookie.txt https:///api/domains?name_only= + curl -X GET -i -b cookie.txt https:///api/domains?name_only=&disabled_only= + + Optional parameters: + + @name_only - Return only domain names, no profiles. + @disabled_only - Return profiles of disabled domains. + + Values of above 2 parameters don't matter at all, for example, these 2 + values are the same: `name_only=`, `name_only=yes`. + """ + name_only = False + disabled_only = False + + form = web.input() + if 'name_only' in form: + name_only = True + + if 'disabled_only' in form: + disabled_only = True + + qr = sql_lib_domain.get_all_managed_domains(name_only=name_only, disabled_only=disabled_only) + if qr[0]: + if name_only: + return api_render((True, qr[1])) + else: + profiles = {} + for i in qr[1]: + domain = str(i.domain).lower() + profiles[domain] = api_utils.export_sql_record(record=i) + + return api_render((True, profiles)) + else: + return api_render(qr) + + +class APIDomain: + @decorators.api_require_domain_access + def GET(self, domain): + """Export SQL record of mail domain as a dict. + + curl -X GET -i -b cookie.txt https:///api/domain/ + """ + domain = str(domain).lower() + + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_domain.profile(domain=domain) + if qr[0]: + profile = api_utils.export_sql_record(record=qr[1]) + + # + # Get all alias domains + # + _qr = sql_lib_domain.get_all_alias_domains(domain=domain, + name_only=True, + conn=conn) + if _qr[0]: + profile['alias_domains'] = _qr[1] + + # + # Get per-domain sender dependent relayhost + # + (_status, _result) = sql_lib_general.get_sender_relayhost(sender='@' + domain) + if _status: + profile['relayhost'] = _result + + # + # Get allocated domain quota + # + _quota = sql_lib_domain.get_allocated_domain_quota(domains=[domain]) + profile['allocated_quota'] = _quota + + return api_render((True, profile)) + else: + return api_render(qr) + + @decorators.api_require_global_admin + def POST(self, domain): + """Create a new domain. + + curl -X POST -i -b cookie.txt -d "defaultQuota=1024" https:///api/domain/ + + Parameters: + + @name - the short description of this domain name. e.g. company name. + @quota - per-domain mailbox quota, in MB. + @language - default preferred language for new user. + e.g. en_US for English, de_DE for Deutsch. + @transport - per-domain transport + @defaultQuota - default mailbox quota for new user. + @maxUserQuota - Max mailbox quota of a single mail user + @numberOfUsers - Max number of mail user accounts + @numberOfAliases - Max number of mail alias accounts + """ + form = web.input() + form['domainName'] = domain + form['cn'] = form.get('name') + + form['preferredLanguage'] = form.get('language', '') + form['mtaTransport'] = form.get('transport', '') + + form['domainQuota'] = form.get('quota') + form['domainQuotaUnit'] = 'MB' + + qr = sql_lib_domain.add(form=form) + return api_render(qr) + + @decorators.api_require_domain_access + def DELETE(self, domain, keep_mailbox_days=0): + """Delete an existing mail domain. + + curl -X DELETE -i -b cookie.txt https:///api/domain/ + curl -X DELETE -i -b cookie.txt https:///api/domain//keep_mailbox_days/ + """ + qr = sql_lib_domain.delete_domains(domains=[domain], keep_mailbox_days=keep_mailbox_days) + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, domain): + """Update domain profile. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/domain/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///api/domain/ + + :param domain: domain name. + + Form parameters: + + `name`: the short company/orgnization name + `accountStatus`: enable or disable domain. possible value is: active, disabled. + `quota`: Per-domain mailbox quota + `transport`: Per-domain transport + + `language`: default preferred language for new user. + e.g. en_US for English, de_DE for Deutsch. + + `minPasswordLength`: Minimal password length + `maxPasswordLength`: Maximum password length + + `defaultQuota`: default mailbox quota for new user. + `maxUserQuota`: Max mailbox quota of a single mail user + + `numberOfUsers`: Max number of mail user accounts + `numberOfAliases`: Max number of mail alias accounts + + `senderBcc`: set bcc address for outgoing emails + `recipientBcc`: set bcc address for incoming emails + + `catchall`: set per-domain catch-all account. + catchall account is a list of email address which will + receive emails sent to non-existing address under same + domain + + `outboundRelay`: relay outgoing emails to specified host + + `addService`: enable new services. Multiple services must be separated by comma. + `removeService`: disable existing services. Multiple services must be separated by comma. + `services`: reset all services. If empty, all existing services will be removed. + + `disableDomainProfile`: disable given domain profiles. Normal admin + cannot view and update disabled profiles in + domain profile page. + `enableDomainProfile`: enable given domain profiles. Normal admin + can view and update disabled profiles in + domain profile page. + `disableUserProfile`: disable given user profiles. Normal admin + cannot view and update disabled profiles in + user profile page. + `enableUserProfile`: enable given domain profiles. Normal admin + can view and update disabled profiles in + user profile page. + `disableUserPreference`: disable given user preferences in + self-service page. Normal mail user cannot + view and update disabled preferences. + `enableUserPreference`: disable given user preferences in + self-service page. Normal mail user can + view and update disabled preferences. + `aliasDomains`: remove all existing alias domains and add given + domains as alias domains. Multiple domains must be + separated by comma. + `addAliasDomain`: add new alias domains. Multiple domains must be + separated by comma. + `removeAliasDomain`: remove existing alias domains. Multiple + domains must be separated by comma. + """ + form = web.input() + qr = sql_lib_domain.api_update_profile(domain=domain, form=form) + return api_render(qr) + + +class APIDomainAdmin: + @decorators.api_require_domain_access + def GET(self, domain): + """List all existing domain admins. + + curl -X GET -i -b cookie.txt https:///api/domain/admins/ + """ + qr = sql_lib_domain.get_domain_admin_addresses(domain=domain) + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, domain): + """Update domain admins. + + curl -X PUT -i -b cookie.txt -d "var=[,,...]" https:///api/domain/admins/ + + Parameters: + + @addAdmin - Add new domain admin. Multiple admins must be separated by comma. + @removeAdmin - Remove existing domain admin. Multiple admins must be separated by comma. + @removeAllAdmin - Remove all existing domain admins. + """ + form = web.input() + qr = sql_lib_domain.api_update_domain_admins(domain=domain, form=form) + return api_render(qr) diff --git a/controllers/sql/api_misc.py b/controllers/sql/api_misc.py new file mode 100644 index 0000000..f570c68 --- /dev/null +++ b/controllers/sql/api_misc.py @@ -0,0 +1,45 @@ +import web + +from controllers.utils import api_render +from libs import iredpwd + +from libs.sqllib import decorators +from libs.sqllib import user as sql_lib_user +from libs.sqllib import admin as sql_lib_admin + + +class APIVerifyPassword: + @decorators.api_require_global_admin + def POST(self, account_type, mail): + """Verify submitted (plain) password against the one stored in SQL db. + + curl -X POST -i -b cookie.txt -d "var=" https:///api/verify_password/user/ + curl -X POST -i -b cookie.txt -d "var=" https:///api/verify_password/admin/ + + Required parameters: + + @password - plain password you want to verify + """ + mail = str(mail).lower() + + form = web.input() + pw = form.get('password', '') + + if not pw: + return api_render((False, 'EMPTY_PASSSWORD')) + + try: + if account_type == 'admin': + qr = sql_lib_admin.get_profile(mail=mail, columns=['password'], conn=None) + else: + qr = sql_lib_user.simple_profile(mail=mail, columns=['password']) + + if qr[0]: + pw_in_db = str(qr[1].password) + qr_pw = iredpwd.verify_password_hash(pw_in_db, pw) + + return api_render(qr_pw) + else: + return api_render(qr) + except Exception as e: + return api_render((False, repr(e))) diff --git a/controllers/sql/api_ml.py b/controllers/sql/api_ml.py new file mode 100644 index 0000000..43c95e2 --- /dev/null +++ b/controllers/sql/api_ml.py @@ -0,0 +1,123 @@ +# Author: Zhang Huangbin + +import web + +from controllers.utils import api_render +from libs.sqllib import decorators +from libs.sqllib import ml as sql_lib_ml +from libs.sqllib import api_utils + +session = web.config.get('_session') + + +class APIMLS: + @decorators.api_require_domain_access + def GET(self, domain): + """List all mailing lists in given domain. + + curl -X GET -i -b cookie.txt https:///api/mls/ + + Optional parameters: + + @email_only -- return a list of mailing list addresses. + if not present, return a list of mailing list profiles + (dicts). + """ + domain = str(domain).lower() + + form = web.input(_unicode=True) + email_only = ('email_only' in form) + + qr = sql_lib_ml.get_basic_ml_profiles(domain=domain, + email_only=email_only, + conn=None) + + if qr[0]: + if email_only: + emails = qr[1] + return api_render((True, emails)) + else: + profiles = api_utils.export_sql_records(records=qr[1]) + return api_render((True, profiles)) + else: + return api_render(qr) + + +class APIML: + @decorators.api_require_domain_access + def GET(self, mail): + """Export mailing list profile. + + curl -X GET -i -b cookie.txt https:///api/ml/ + + Optional arguments: + + @with_subscribers -- if set to 'yes', all subscribers will be returned. + """ + mail = str(mail).lower() + + form = web.input(_unicode=False) + with_subscribers = ('with_subscribers' in form) + + qr = sql_lib_ml.get_profile(mail=mail, + with_subscribers=with_subscribers, + conn=None) + + if qr[0]: + profile = api_utils.export_sql_record(record=qr[1]) + return api_render((True, profile)) + else: + return api_render(qr) + + @decorators.api_require_domain_access + def DELETE(self, mail): + """Delete a mailing list. + + curl -X DELETE -i -b cookie.txt https:///api/ml/ + """ + mail = str(mail).lower() + form = web.input() + + keep_archive = True + if form.get('keep_archive') == 'no': + keep_archive = False + + qr = sql_lib_ml.delete_maillists(accounts=[mail], + keep_archive=keep_archive, + conn=None) + return api_render(qr) + + @decorators.api_require_domain_access + def POST(self, mail): + """Create a new mail alias account. + + curl -X POST -i -b cookie.txt -d "..." https:///api/ml/ + + Optional POST parameters: + + """ + mail = str(mail).lower() + (listname, domain) = mail.split('@', 1) + + form = web.input() + + form['listname'] = listname + form['domainName'] = domain + + qr = sql_lib_ml.add_ml_from_web_form(domain=domain, form=form, conn=None) + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, mail): + """Update mailing list profile. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/ml/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///ml/ + + Optional PUT data: + + """ + mail = str(mail).lower() + form = web.input() + qr = sql_lib_ml.api_update_profile(mail=mail, form=form, conn=None) + return api_render(qr) diff --git a/controllers/sql/api_user.py b/controllers/sql/api_user.py new file mode 100644 index 0000000..4319b1a --- /dev/null +++ b/controllers/sql/api_user.py @@ -0,0 +1,340 @@ +# Author: Zhang Huangbin + +import web + +import settings +from controllers.utils import api_render + +from libs import form_utils, iredpwd +from libs.logger import log_activity +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import user as sql_lib_user +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import general as sql_lib_general +from libs.sqllib import api_utils + +session = web.config.get('_session') + + +class APIUser: + @decorators.api_require_domain_access + def GET(self, mail): + """Export SQL record of mail user as a dict. + + curl -X GET -i -b cookie.txt https:///api/user/ + """ + mail = str(mail).lower() + + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_user.profile(mail=mail, + with_aliases=True, + with_alias_groups=True, + with_mailing_lists=True, + with_forwardings=True, + with_used_quota=True, + with_last_login=True, + conn=conn) + if qr[0]: + profile = api_utils.export_sql_record(record=qr[1], + remove_columns=settings.API_HIDDEN_USER_PROFILES) + + if profile.get('isadmin') == 1: + # Get all managed domains + _qr = sql_lib_admin.get_managed_domains(admin=mail, + domain_name_only=True, + listed_only=True, + conn=conn) + if _qr[0]: + profile['managed_domains'] = _qr[1] + + return api_render((True, profile)) + else: + return api_render(qr) + + @decorators.api_require_domain_access + def POST(self, mail): + """Create a new mail user. + + curl -X POST -i -b cookie.txt -d "var=value1&var2=value2&..." https:///api/user/ + + Optional POST data: + + @name - display name + @password - password + @password_hash - password hash + @language - default preferred language for new user. e.g. + en_US for English, de_DE for Deutsch. + @quota - mailbox quota for this user (in MB). + """ + mail = str(mail).lower() + (username, domain) = mail.split('@', 1) + + if not session.get('is_global_admin'): + sql_lib_user.redirect_if_user_is_global_admin(conn=None, mail=mail) + + form = web.input() + + form['username'] = username + form['domainName'] = domain + + form['preferredLanguage'] = form.get('language') + form['cn'] = form.get('name') + + _pw = form.get('password', '') + if _pw: + form['newpw'] = _pw + form['confirmpw'] = _pw + else: + _pw_hash = form.get('password_hash', '') + form['password_hash'] = _pw_hash + + # Set quota + form['mailQuota'] = form.get('quota') + + qr = sql_lib_user.add_user_from_form(domain=domain, form=form) + return api_render(qr) + + @decorators.api_require_domain_access + def DELETE(self, mail, keep_mailbox_days=0): + """Delete a mail user. + + curl -X DELETE -i -b cookie.txt https:///api/user/ + curl -X DELETE -i -b cookie.txt https:///api/user//keep_mailbox_days/ + """ + mail = str(mail).lower() + + _wrap = SQLWrap() + conn = _wrap.conn + + if not session.get('is_global_admin'): + sql_lib_user.redirect_if_user_is_global_admin(conn=conn, mail=mail) + + qr = sql_lib_user.delete_users(conn=conn, accounts=[mail], keep_mailbox_days=keep_mailbox_days) + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, mail): + """Update user profile. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/user/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///api/user/ + + Optional PUT data: + + @name - common name (or, display name) + @password - set new password for user + @password_hash - set new password to given hashed password + @quota - mailbox quota for this user (in MB). + @accountStatus - enable or disable user. possible value is: active, disabled. + @language - set preferred language of web UI + @employeeid - set employee id + @transport - set per-user transport + @isGlobalAdmin -- promote user to be a global admin + @forwarding -- set per-user mail forwarding addresseses + @addForwarding -- add per-user mail forwarding addresses + @removeForwarding -- remove existing per-user mail forwarding addresses + @senderBcc -- set per-user bcc for outbound emails + @recipientBcc -- set per-user bcc for inbound emails + @aliases -- reset per-user alias addresses + @addAlias -- add new per-user alias addresses + @removeAlias -- remove existing per-user alias addresses + @maildir -- full maildir path of the mailbox + """ + mail = str(mail).lower() + form = web.input() + qr = sql_lib_user.api_update_profile(mail=mail, form=form, conn=None) + return api_render(qr) + + +class APIUsers: + @decorators.api_require_domain_access + def GET(self, domain): + """List all mail users in given domain. + + curl -X GET -i -b cookie.txt https:///api/users/ + + Optional parameters: + + @email_only -- return a list of users' email addresses. + if not present, return a list of user profiles + (dicts). + @disabled_only -- return disabled users. + """ + domain = str(domain).lower() + + form = web.input(_unicode=True) + email_only = ('email_only' in form) + disabled_only = ('disabled_only' in form) + + qr = sql_lib_user.get_basic_user_profiles(domain=domain, + email_only=email_only, + disabled_only=disabled_only, + with_last_login=True, + with_used_quota=True, + conn=None) + + if qr[0]: + if email_only: + emails = qr[1] + return api_render((True, emails)) + else: + profiles = api_utils.export_sql_records(records=qr[1]) + return api_render((True, profiles)) + else: + return api_render(qr) + + @decorators.api_require_domain_access + def PUT(self, domain): + """Update profiles of users under domain. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/users/ + curl -X PUT -i -b cookie.txt -d "var=&var2=" https:///api/users/ + + Optional PUT data: + + @name - common name (or, display name) + @accountStatus - enable or disable user. possible value is: active, disabled. + @language - set preferred language of web UI + @transport - set per-user transport + @password - reset all users' password. + """ + domain = str(domain).lower() + + form = web.input() + params = {} + + # Name + kv = form_utils.get_form_dict(form, + input_name='name', + key_name='name') + params.update(kv) + + # Account status + kv = form_utils.get_form_dict(form, + input_name='accountStatus', + key_name='active') + params.update(kv) + + # Language + kv = form_utils.get_form_dict(form, + input_name='language', + to_string=True) + params.update(kv) + + # Transport + kv = form_utils.get_form_dict(form, + input_name='transport', + to_string=True) + params.update(kv) + + _wrap = SQLWrap() + conn = _wrap.conn + + # Password + if "password" in form: + pw = form_utils.get_single_value(form, + input_name="password", + default_value="", + to_string=True) + + if not pw: + return api_render((False, "EMPTY_PASSWORD")) + + qr = sql_lib_general.get_domain_settings(domain=domain, conn=conn) + if not qr[0]: + return api_render(qr) + + ds = qr[1] + min_passwd_length = ds.get('min_passwd_length', settings.min_passwd_length) + max_passwd_length = ds.get('max_passwd_length', settings.max_passwd_length) + + qr = iredpwd.verify_new_password(newpw=pw, + confirmpw=pw, + min_passwd_length=min_passwd_length, + max_passwd_length=max_passwd_length) + if qr[0]: + params["password"] = iredpwd.generate_password_hash(pw) + else: + return api_render(qr) + + if not params: + return api_render(True) + + try: + + conn.update('mailbox', + vars={'domain': domain}, + where='domain=$domain', + **params) + + try: + # Log updated parameters and values if possible + msg = str(params) + except: + msg = ', '.join(params) + + log_activity(msg="Update profiles of all users under domain: {} -> {}".format(domain, msg), + admin=session.get('username'), + username=domain, + domain=domain, + event='update') + + return api_render(True) + except Exception as e: + return api_render((False, repr(e))) + + +class APIUsersPassword: + @decorators.api_require_domain_access + def PUT(self, domain): + """Update password of all users under domain. + + curl -X PUT -i -b cookie.txt -d "var=" https:///api/users//password + + Required parameters: + + @password - set new password for user + """ + domain = str(domain).lower() + + form = web.input() + + qr = api_utils.get_form_password_dict(form=form, + domain=domain, + input_name='password') + if qr[0]: + pw_hash = qr[1]['pw_hash'] + + _wrap = SQLWrap() + conn = _wrap.conn + + conn.update('mailbox', + vars={'domain': domain}, + where='domain=$domain', + password=pw_hash) + + log_activity(msg="Update all users' password under domain: %s" % domain, + admin=session.get('username'), + username=domain, + domain=domain, + event='update') + + return api_render(True) + else: + return api_render(qr) + + +class APIChangeEmail: + @decorators.api_require_domain_access + def POST(self, mail, new_mail): + """Change user email address. + + curl -X POST -i -b cookie.txt https:///api/user//change_email/ + """ + mail = str(mail).lower() + new_mail = str(new_mail).lower() + + qr = sql_lib_user.change_email(mail=mail, new_mail=new_mail) + return api_render(qr) diff --git a/controllers/sql/basic.py b/controllers/sql/basic.py new file mode 100644 index 0000000..986ee55 --- /dev/null +++ b/controllers/sql/basic.py @@ -0,0 +1,501 @@ +# Author: Zhang Huangbin + +import web +import settings + +from controllers.utils import api_render + +from libs import __version_sql__ as __version__ +from libs import iredutils, sysinfo, form_utils +from libs.logger import logger, log_activity + +from libs.sqllib import SQLWrap, auth, decorators +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import utils as sql_lib_utils +from libs.sqllib import general as sql_lib_general + +if settings.iredapd_enabled: + from libs.iredapd import log as iredapd_log + +if settings.fail2ban_enabled: + from libs.f2b import log as f2b_log + +if settings.amavisd_enable_quarantine or settings.amavisd_enable_logging: + from libs.amavisd import log as lib_amavisd_log + + +session = web.config.get('_session') + + +class Login: + def GET(self): + if not session.get('logged'): + form = web.input(_unicode=False) + + if not iredutils.is_allowed_admin_login_ip(client_ip=web.ctx.ip): + return web.render('error_without_login.html', + error='NOT_ALLOWED_IP') + + # Show login page. + return web.render('login.html', + languagemaps=iredutils.get_language_maps(), + msg=form.get('msg')) + else: + if session.get('account_is_mail_user'): + iredutils.self_service_login_redirect(session['username']) + else: + if settings.REDIRECT_TO_DOMAIN_LIST_AFTER_LOGIN: + raise web.seeother('/domains') + else: + raise web.seeother('/dashboard') + + def POST(self): + # Get username, password. + form = web.input(_unicode=False) + + username = form.get('username', '').strip().lower() + password = str(form.get('password', '').strip()) + domain = username.split('@', 1)[-1] + + # Auth as domain admin + _wrap = SQLWrap() + conn = _wrap.conn + + auth_result = auth.auth(conn=conn, + username=username, + password=password, + account_type='admin') + + if auth_result[0]: + log_activity(msg="Admin login success.", domain=domain, event='login') + + # Save selected language + selected_language = str(form.get('lang', '')).strip() + if selected_language != web.ctx.lang and \ + selected_language in iredutils.get_language_maps(): + session['lang'] = selected_language + + account_settings = auth_result[1].get('account_settings', {}) + if (not session.get('is_global_admin')) and 'create_new_domains' in account_settings: + session['create_new_domains'] = True + + for k in ['disable_viewing_mail_log', + 'disable_managing_quarantined_mails']: + if account_settings.get(k) == 'yes': + session[k] = True + + if settings.REDIRECT_TO_DOMAIN_LIST_AFTER_LOGIN: + raise web.seeother('/domains') + else: + raise web.seeother('/dashboard?checknew') + else: + # + # User login for self-service + # + # Check enabled services. + qr = sql_lib_domain.get_domain_enabled_services(domain=domain, conn=conn) + + if qr[0]: + enabled_services = qr[1] + if 'self-service' not in enabled_services: + # domain doesn't allow self-service + raise web.seeother('/login?msg=INVALID_CREDENTIALS') + else: + raise web.seeother('/login?msg=INVALID_CREDENTIALS') + + user_auth_result = auth.auth(conn=conn, + username=username, + password=password, + account_type='user') + + if user_auth_result[0]: + log_activity(msg="User login success", event='user_login') + + account_settings = user_auth_result[1].get('account_settings', {}) + if (not session.get('is_global_admin')) and \ + 'create_new_domains' in account_settings: + session['create_new_domains'] = True + + iredutils.self_service_login_redirect(session['username']) + else: + session['failed_times'] += 1 + logger.warning("Web login failed: client_address={}, username={}".format(web.ctx.ip, username)) + log_activity(msg="Login failed.", admin=username, event='login', loglevel='error') + raise web.seeother('/login?msg=%s' % web.urlquote(auth_result[1])) + + +class Logout: + def GET(self): + try: + session.kill() + except: + pass + + raise web.seeother('/login') + + +class Dashboard: + @decorators.require_admin_login + def GET(self): + form = web.input(_unicode=False) + _check_new_version = ('checknew' in form) + + # Check new version. + if session.get('is_global_admin') and _check_new_version: + (_status, _info) = sysinfo.check_new_version() + session['new_version_available'] = _status + if _status: + session['new_version'] = _info + else: + session['new_version_check_error'] = _info + + # Get numbers of domains, users, aliases. + num_existing_domains = 0 + num_existing_users = 0 + num_existing_lists = 0 + num_existing_aliases = 0 + + _wrap = SQLWrap() + conn = _wrap.conn + + try: + num_existing_domains = sql_lib_admin.num_managed_domains(conn=conn) + num_existing_users = sql_lib_admin.num_managed_users(conn=conn) + num_existing_lists = sql_lib_admin.num_managed_lists(conn=conn) + num_existing_aliases = sql_lib_admin.num_managed_aliases(conn=conn) + except: + pass + + # + # For normal domain admin + # + # Get number of max domains/users,aliases. (-1 means no limitation) + num_max_domains = -1 + num_max_users = -1 + num_max_lists = -1 + num_max_aliases = -1 + + admin = session.get('username') + if (not session.get('is_global_admin')) and session.get('create_new_domains'): + # Get account settings + qr = sql_lib_general.get_admin_settings(admin=admin, conn=conn) + + if qr[0]: + account_settings = qr[1] + num_max_domains = account_settings.get('create_max_domains', -1) + num_max_users = account_settings.get('create_max_users', -1) + num_max_lists = account_settings.get('create_max_lists', -1) + num_max_aliases = account_settings.get('create_max_aliases', -1) + + # Get numbers of existing messages and quota bytes. + # Set None as default, so that it's easy to detect them in Jinja2 template. + total_messages = None + total_bytes = None + if session.get('is_global_admin'): + if settings.SHOW_USED_QUOTA: + try: + qr = sql_lib_admin.sum_all_used_quota(conn=conn) + total_messages = qr['messages'] + total_bytes = qr['bytes'] + except: + pass + + # Get number of incoming/outgoing emails in latest 24 hours. + last_hours = settings.STATISTICS_HOURS + last_seconds = last_hours * 60 * 60 + num_incoming_mails = 0 + num_outgoing_mails = 0 + num_virus = 0 + num_quarantined = 0 + # iRedAPD + num_rejected = 0 + num_smtp_outbound_sessions = 0 + + top_senders = [] + top_recipients = [] + + all_reversed_domain_names = [] + + if settings.amavisd_enable_logging or settings.amavisd_enable_quarantine: + # Get all managed domain names and reversed names. + _all_domains = [] + result_all_domains = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True) + if result_all_domains[0]: + _all_domains += result_all_domains[1] + + all_reversed_domain_names = iredutils.reverse_amavisd_domain_names(_all_domains) + + if settings.amavisd_enable_logging: + num_incoming_mails = lib_amavisd_log.count_incoming_mails(all_reversed_domain_names, last_seconds) + num_outgoing_mails = lib_amavisd_log.count_outgoing_mails(all_reversed_domain_names, last_seconds) + num_virus = lib_amavisd_log.count_virus_mails(all_reversed_domain_names, last_seconds) + + top_senders = lib_amavisd_log.get_top_users( + reversedDomainNames=all_reversed_domain_names, + log_type='sent', + timeLength=last_seconds, + number=settings.NUM_TOP_SENDERS, + ) + + top_recipients = lib_amavisd_log.get_top_users( + reversedDomainNames=all_reversed_domain_names, + log_type='received', + timeLength=last_seconds, + number=settings.NUM_TOP_RECIPIENTS, + ) + + # Get records of quarantined mails. + if settings.amavisd_enable_quarantine: + num_quarantined = lib_amavisd_log.count_quarantined(all_reversed_domain_names, last_seconds) + + if settings.iredapd_enabled: + num_rejected = iredapd_log.get_num_rejected(hours=last_hours) + num_smtp_outbound_sessions = iredapd_log.get_num_smtp_outbound_sessions( + hours=last_hours, + ) + + num_banned = 0 + if session.get('is_global_admin') and settings.fail2ban_enabled: + num_banned = f2b_log.num_banned() + + return web.render( + 'dashboard.html', + version=__version__, + iredmail_version=sysinfo.get_iredmail_version(), + hostname=sysinfo.get_hostname(), + uptime=sysinfo.get_server_uptime(), + loadavg=sysinfo.get_system_load_average(), + netif_data=sysinfo.get_nic_info(), + # number of existing accounts + num_existing_domains=num_existing_domains, + num_existing_users=num_existing_users, + num_existing_lists=num_existing_lists, + num_existing_aliases=num_existing_aliases, + # number of account limitation + num_max_domains=num_max_domains, + num_max_users=num_max_users, + num_max_lists=num_max_lists, + num_max_aliases=num_max_aliases, + total_messages=total_messages, + total_bytes=total_bytes, + # amavisd statistics + num_incoming_mails=num_incoming_mails, + num_outgoing_mails=num_outgoing_mails, + num_virus=num_virus, + num_quarantined=num_quarantined, + top_senders=top_senders, + top_recipients=top_recipients, + removeQuarantinedInDays=settings.AMAVISD_REMOVE_QUARANTINED_IN_DAYS, + # iRedAPD + num_rejected=num_rejected, + num_smtp_outbound_sessions=num_smtp_outbound_sessions, + # Fail2ban + num_banned=num_banned, + ) + + +class Search: + @decorators.require_admin_login + def GET(self): + form = web.input() + return web.render('sql/search.html', msg=form.get('msg')) + + @decorators.csrf_protected + @decorators.require_admin_login + def POST(self): + form = web.input(account_type=[], accountStatus=[]) + search_string = form.get('searchString', '').strip() + if not search_string: + raise web.seeother('/search?msg=EMPTY_STRING') + + account_type = form.get('account_type', []) + account_status = form.get('accountStatus', []) + + try: + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_utils.search(conn=conn, + search_string=search_string, + account_type=account_type, + account_status=account_status) + if not qr[0]: + return web.render('sql/search.html', + msg=qr[1], + searchString=search_string) + except Exception as e: + return web.render('sql/search.html', + msg=repr(e), + searchString=search_string) + + # Group account types. + domains = qr[1].get('domain', []) + admins = qr[1].get('admin', []) + users = qr[1].get('user', []) + mls = qr[1].get('ml', []) + last_logins = qr[1]['last_logins'] + user_alias_addresses = qr[1]['user_alias_addresses'] + user_forwarding_addresses = qr[1]['user_forwarding_addresses'] + user_assigned_groups = qr[1]['user_assigned_groups'] + aliases = qr[1].get('alias', []) + all_global_admins = qr[1].get('allGlobalAdmins', []) + total_results = len(domains) + len(admins) + len(users) + len(aliases) + len(mls) + + if session.get('is_global_admin'): + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX_FOR_GLOBAL_ADMIN + else: + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX + + return web.render('sql/search.html', + searchString=search_string, + total_results=total_results, + domains=domains, + admins=admins, + users=users, + mls=mls, + last_logins=last_logins, + user_alias_addresses=user_alias_addresses, + user_forwarding_addresses=user_forwarding_addresses, + user_assigned_groups=user_assigned_groups, + aliases=aliases, + allGlobalAdmins=all_global_admins, + days_to_keep_removed_mailbox=days_to_keep_removed_mailbox, + msg=form.get('msg')) + + +class OperationsFromSearchPage: + @decorators.require_admin_login + def GET(self, *args, **kw): + raise web.seeother('/search') + + @decorators.csrf_protected + @decorators.require_admin_login + def POST(self, account_type): + account_type = web.safestr(account_type) + form = web.input(_unicode=False, mail=[]) + + # Get action. + action = form.get('action', None) + if action not in ['enable', 'disable', 'delete']: + raise web.seeother('/search?msg=INVALID_ACTION') + + # Get list of accounts which has valid format. + accounts = [web.safestr(v).lower() + for v in form.get('mail', []) + if iredutils.is_email(web.safestr(v))] + + # Raise earlier to avoid SQL query. + if not accounts: + raise web.seeother('/search?msg=SUCCESS') + + domains = {v.split('@', 1)[-1] for v in accounts} + + _wrap = SQLWrap() + conn = _wrap.conn + + # Get managed accounts. + if not session.get('is_global_admin'): + # Get list of managed domains. + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True, + listed_only=True) + if qr[0]: + domains = [d for d in domains if d in qr[1]] + accounts = [v for v in accounts if v.split('@', 1)[-1] in domains] + else: + raise web.seeother('/search?msg=%s' % web.urlquote(qr[1])) + + if not accounts: + raise web.seeother('/search?msg=SUCCESS') + + if action in ['enable']: + qr = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type=account_type, + enable_account=True) + elif action in ['disable']: + qr = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type=account_type, + enable_account=False) + elif action in ['delete']: + keep_mailbox_days = 0 # keep forever + if account_type in ['user', 'domain']: + keep_mailbox_days = form_utils.get_single_value(form=form, + input_name='keep_mailbox_days', + default_value=0, + is_integer=True) + try: + keep_mailbox_days = int(keep_mailbox_days) + except: + if session.get('is_global_admin'): + keep_mailbox_days = 0 + else: + _max_days = max(settings.DAYS_TO_KEEP_REMOVED_MAILBOX) + if keep_mailbox_days > _max_days: + # Get the max days + keep_mailbox_days = _max_days + + qr = sql_lib_utils.delete_accounts(accounts=accounts, + account_type=account_type, + keep_mailbox_days=keep_mailbox_days, + conn=conn) + else: + raise web.seeother("/search?msg=INVALID_ACTION") + + if qr[0]: + raise web.seeother('/search?msg=SUCCESS') + else: + raise web.seeother('/search?msg=%s' % str(qr[1])) + + +class APILogin: + def GET(self): + return api_render((False, 'INVALID_HTTP_METHOD')) + + def POST(self): + """Login. + + curl -X POST -c cookie.txt -d "username=&password=" https:///api/login + + Required POST data: + + @username - valid email address of domain admin + @password - password of username + """ + if not iredutils.is_allowed_api_client(web.ctx.ip): + return api_render((False, 'NOT_AUTHORIZED')) + + # Get username, password. + form = web.input(_unicode=False) + + username = form.get('username', '').strip().lower() + password = web.safestr(form.get('password', '').strip()) + domain = username.split("@", 1)[-1] + + # Auth as domain admin + _wrap = SQLWrap() + conn = _wrap.conn + + auth_result = auth.auth(conn=conn, + username=username, + password=password, + account_type='admin') + + if auth_result[0]: + log_activity(msg="Admin login success.", domain=domain, event='login') + + return api_render(True) + else: + session['failed_times'] += 1 + logger.warning("API login failed: client_address={}, username={}".format(web.ctx.ip, username)) + log_activity(msg="Admin login failed.", + admin=username, + domain=domain, + event='login', + loglevel='error') + return api_render(auth_result) diff --git a/controllers/sql/domain.py b/controllers/sql/domain.py new file mode 100644 index 0000000..c54be99 --- /dev/null +++ b/controllers/sql/domain.py @@ -0,0 +1,368 @@ +# Author: Zhang Huangbin + +import web +import settings + +from libs import iredutils, form_utils +from libs.l10n import TIMEZONES + +from libs.sqllib import SQLWrap, decorators, sqlutils +from libs.sqllib import alias as sql_lib_alias +from libs.sqllib import ml as sql_lib_ml +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import admin as sql_lib_admin + +from libs.amavisd import spampolicy as spampolicylib, wblist as lib_wblist + +from libs.panel.domain_ownership import get_pending_domains + +session = web.config.get('_session') + +if settings.iredapd_enabled: + from libs.iredapd import throttle as iredapd_throttle + from libs.iredapd import greylist as iredapd_greylist + + +class List: + @decorators.require_admin_login + def GET(self, cur_page=1, disabled_only=False): + """List paged mail domains.""" + form = web.input(_unicode=False) + cur_page = int(cur_page) or 1 + + all_domain_profiles = [] + domain_used_quota = {} + all_first_chars = [] + + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + # Get first characters of all domains - no matter whether it's + # requested to list all domains or disabled only. + _qr = sql_lib_domain.get_first_char_of_all_domains(conn=conn) + if _qr[0]: + all_first_chars = _qr[1] + + total = sql_lib_admin.num_managed_domains(conn=conn, + disabled_only=disabled_only, + first_char=first_char) + + if total: + qr = sql_lib_domain.get_paged_domains(cur_page=cur_page, + first_char=first_char, + disabled_only=disabled_only, + conn=conn) + if qr[0]: + all_domain_profiles = qr[1] + + if settings.SHOW_USED_QUOTA: + domains = [] + for i in all_domain_profiles: + domains.append(str(i.domain)) + + domain_used_quota = sql_lib_domain.get_domain_used_quota(conn=conn, + domains=domains) + + # Get alias domain names. + all_domain_names = [] + all_alias_domains = {} + if all_domain_profiles: + all_domain_names = [str(d.domain).lower() for d in all_domain_profiles] + qr = conn.select('alias_domain', + vars={'all_domain_names': all_domain_names}, + what='alias_domain, target_domain', + where='target_domain IN $all_domain_names') + + if qr: + for r in qr: + td = str(r.target_domain).lower() + ad = str(r.alias_domain).lower() + + if td in all_alias_domains: + all_alias_domains[td].append(ad) + else: + all_alias_domains[td] = [ad] + + # Query pending domains which didn't passed ownership verification + pending_domains = [] + if all_domain_names: + qr = get_pending_domains(domains=all_domain_names, domain_name_only=True) + if qr[0]: + pending_domains = qr[1] + + if session.get('is_global_admin'): + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX_FOR_GLOBAL_ADMIN + else: + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX + + return web.render('sql/domain/list.html', + cur_page=cur_page, + total=total, + all_domain_profiles=all_domain_profiles, + all_alias_domains=all_alias_domains, + domain_used_quota=domain_used_quota, + local_transports=settings.LOCAL_TRANSPORTS, + first_char=first_char, + all_first_chars=all_first_chars, + disabled_only=disabled_only, + pending_domains=pending_domains, + days_to_keep_removed_mailbox=days_to_keep_removed_mailbox, + msg=form.get('msg', None)) + + @decorators.require_admin_login + @decorators.csrf_protected + def POST(self): + form = web.input(domainName=[], _unicode=False) + domains = form.get('domainName', []) + action = form.get('action') + + if action not in ['delete', 'enable', 'disable']: + raise web.seeother('/domains?msg=INVALID_ACTION') + + _wrap = SQLWrap() + conn = _wrap.conn + + if not domains: + raise web.seeother('/domains?msg=INVALID_DOMAIN_NAME') + + if session.get('is_global_admin') or session.get('create_new_domains'): + if action == 'delete': + keep_mailbox_days = form_utils.get_single_value(form=form, + input_name='keep_mailbox_days', + default_value=0, + is_integer=True) + + qr = sql_lib_domain.delete_domains(domains=domains, + keep_mailbox_days=keep_mailbox_days, + conn=conn) + msg = 'DELETED' + + if action in ['enable', 'disable']: + qr = sql_lib_domain.enable_disable_domains(domains=domains, + action=action, + conn=conn) + + # msg: ENABLED, DISABLED + msg = action.upper() + 'D' + + if qr[0]: + raise web.seeother('/domains?msg=%s' % msg) + else: + raise web.seeother('/domains?msg=' + web.urlquote(qr[1])) + + +class ListDisabled: + """List disabled mail domains.""" + @decorators.require_admin_login + def GET(self, cur_page=1): + lst = List() + return lst.GET(cur_page=cur_page, disabled_only=True) + + +class Profile: + @decorators.require_domain_access + def GET(self, profile_type, domain): + form = web.input() + domain = web.safestr(domain.split('/', 1)[0]) + profile_type = web.safestr(profile_type) + + _wrap = SQLWrap() + conn = _wrap.conn + + result = sql_lib_domain.profile(conn=conn, domain=domain) + + if result[0] is not True: + raise web.seeother('/domains?msg=' + web.urlquote(result[1])) + + domain_profile = result[1] + + alias_domains = [] # Get all alias domains. + all_alias_accounts = [] # Get all mail alias accounts. + all_mailing_lists = [] + + # profile_type == 'throttle' + # throttle: iRedAPD + gl_setting = {} + gl_whitelists = [] + inbound_throttle_setting = {} + outbound_throttle_setting = {} + + # Get alias domains. + qr = sql_lib_domain.get_all_alias_domains(domain=domain, + name_only=True, + conn=conn) + if qr[0]: + alias_domains = qr[1] + + # Get all mail aliases. + mails_of_all_alias_accounts = [] + qr = sql_lib_alias.get_basic_alias_profiles(conn=conn, + domain=domain, + columns=['name', 'address']) + if qr[0]: + all_alias_accounts = qr[1] + for ali in all_alias_accounts: + mails_of_all_alias_accounts += [ali.address] + + # Get all mailing lists. + mails_of_all_mailing_lists = [] + qr = sql_lib_ml.get_basic_ml_profiles(domain=domain, + columns=['address', 'name'], + conn=conn) + if qr[0]: + all_mailing_lists = qr[1] + for i in all_mailing_lists: + mails_of_all_mailing_lists.append(i['address']) + + # Get per-admin settings used by normal admin to create new domains. + creation_limits = sql_lib_admin.get_per_admin_domain_creation_limits(admin=session.get('username'), conn=conn) + + # Get sender/recipient throttle data from iRedAPD database. + if settings.iredapd_enabled: + _account = '@' + domain + + # Greylisting + gl_setting = iredapd_greylist.get_greylist_setting(account=_account) + gl_whitelists = iredapd_greylist.get_greylist_whitelists(account=_account) + + # Throttling + inbound_throttle_setting = iredapd_throttle.get_throttle_setting(account=_account, + inout_type='inbound') + outbound_throttle_setting = iredapd_throttle.get_throttle_setting(account=_account, + inout_type='outbound') + + spampolicy = {} + global_spam_score = None + if settings.amavisd_enable_policy_lookup: + qr = spampolicylib.get_spam_policy(account='@' + domain) + if not qr[0]: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + spampolicy = qr[1] + + global_spam_score = spampolicylib.get_global_spam_score() + + # Get per-domain white/blacklists + whitelists = [] + blacklists = [] + outbound_whitelists = [] + outbound_blacklists = [] + + qr = lib_wblist.get_wblist(account='@' + domain) + + if qr[0]: + whitelists = qr[1]['inbound_whitelists'] + blacklists = qr[1]['inbound_blacklists'] + outbound_whitelists = qr[1]['outbound_whitelists'] + outbound_blacklists = qr[1]['outbound_blacklists'] + + # Domain ownership verification + pending_domains = [] + qr = get_pending_domains(domains=[domain], domain_name_only=True) + if qr[0]: + pending_domains = qr[1] + + # Get settings from db. + _settings = iredutils.get_settings_from_db(params=['min_passwd_length', 'max_passwd_length']) + global_min_passwd_length = _settings['min_passwd_length'] + global_max_passwd_length = _settings['max_passwd_length'] + + return web.render( + 'sql/domain/profile.html', + cur_domain=domain, + profile_type=profile_type, + profile=domain_profile, + default_mta_transport=settings.default_mta_transport, + domain_settings=sqlutils.account_settings_string_to_dict(domain_profile['settings']), + global_min_passwd_length=global_min_passwd_length, + global_max_passwd_length=global_max_passwd_length, + alias_domains=alias_domains, + all_alias_accounts=all_alias_accounts, + mails_of_all_alias_accounts=mails_of_all_alias_accounts, + all_mailing_lists=all_mailing_lists, + mails_of_all_mailing_lists=mails_of_all_mailing_lists, + timezones=TIMEZONES, + creation_limits=creation_limits, + # iRedAPD + gl_setting=gl_setting, + gl_whitelists=gl_whitelists, + inbound_throttle_setting=inbound_throttle_setting, + outbound_throttle_setting=outbound_throttle_setting, + # Language + languagemaps=iredutils.get_language_maps(), + # Spam policy, wblist + spampolicy=spampolicy, + custom_ban_rules=settings.AMAVISD_BAN_RULES, + global_spam_score=global_spam_score, + whitelists=whitelists, + blacklists=blacklists, + outbound_whitelists=outbound_whitelists, + outbound_blacklists=outbound_blacklists, + # domain ownership verification + pending_domains=pending_domains, + msg=form.get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, profile_type, domain): + domain = str(domain).lower() + + form = web.input(domainAliasName=[], + domainAdmin=[], + default_mail_list=[], + defaultList=[], + enabledService=[], + disabledMailService=[], + disabledDomainProfile=[], + disabledUserProfile=[], + disabledUserPreference=[], + banned_rulenames=[]) + + result = sql_lib_domain.update(profile_type=profile_type, + domain=domain, + form=form) + + if result[0]: + raise web.seeother('/profile/domain/{}/{}?msg=UPDATED'.format(profile_type, domain)) + else: + raise web.seeother('/profile/domain/{}/{}?msg={}'.format(profile_type, domain, web.urlquote(result[1]))) + + +class Create: + @decorators.require_permission_create_domain + def GET(self): + form = web.input() + admin = session.get('username') + + # for normal domain admin: check limitations + creation_limits = sql_lib_admin.get_per_admin_domain_creation_limits(admin=admin) + if creation_limits['error_code']: + msg = None + else: + msg = form.get('msg') + + return web.render('sql/domain/create.html', + preferred_language=settings.default_language, + languagemaps=iredutils.get_language_maps(), + timezones=TIMEZONES, + creation_limits=creation_limits, + msg=msg) + + @decorators.require_permission_create_domain + @decorators.csrf_protected + def POST(self): + form = web.input() + domain = form_utils.get_domain_name(form) + + result = sql_lib_domain.add(form=form) + + if result[0]: + raise web.seeother('/profile/domain/general/%s?msg=CREATED' % domain) + else: + raise web.seeother('/create/domain?msg=%s' % web.urlquote(result[1])) diff --git a/controllers/sql/export.py b/controllers/sql/export.py new file mode 100644 index 0000000..6550621 --- /dev/null +++ b/controllers/sql/export.py @@ -0,0 +1,257 @@ +# Author: Zhang Huangbin + +import zipfile +import io +import csv +import web + +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import general as sql_lib_general +from libs.sqllib import admin as sql_lib_admin + +session = web.config.get('_session') + + +class ExportManagedAccounts: + @decorators.require_admin_login + def GET(self, mail): + mail = mail.lower() + + # Raise error if normal admin is trying to export accounts managed by + # other admin + if (not session.get('is_global_admin')) and session.get('username') != mail: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + qr = sql_lib_general.export_managed_accounts(mail=mail, domains=None, conn=None) + if not qr[0]: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + managed_domains = qr[1] + + # Generate summary + content_summary = ['Accounts managed by admin: {}'.format(mail), '------'] + + _domains = [] + _total_domains = 0 + _total_users = 0 + _total_lists = 0 + _total_aliases = 0 + + for d in managed_domains: + _total_domains += 1 + _domains += [d['domain']] + _total_users += d['total_users'] + _total_lists += d['total_lists'] + _total_aliases += d['total_aliases'] + + content_summary += ['- Domains: {}'.format(_total_domains)] + content_summary += ['- Mailboxes: {}'.format(_total_users)] + content_summary += ['- Mailing lists: {}'.format(_total_lists)] + content_summary += ['- Mail aliases: {}'.format(_total_aliases)] + + # Generate zip file + f = io.BytesIO() + try: + zf = zipfile.ZipFile(f, mode='w', compression=zipfile.ZIP_DEFLATED) + # Summary of all managed accounts + zf.writestr('summary.txt', '\n'.join(content_summary)) + + _content_domains = ['# Exported domains:'] + _content_domains += ['# Format: domain name, display name'] + + # Generate files for each domain + for d in managed_domains: + _domain = d['domain'] + _content_domains += ['{domain}, {name}'.format(**d)] + + for _account_type in ['users', 'lists', 'aliases']: + if d['total_' + _account_type] == 0: + continue + + if _account_type == 'users': + _content = ['# Mailboxes under domain %s' % _domain] + elif _account_type == 'lists': + _content = ['# Mailing lists under domain %s' % _domain] + else: + # account_type == 'aliases' + _content = ['# Mail aliases under domain %s' % _domain] + + _content += ['# Format: mail address, display name'] + + for _account in d[_account_type]: + _content += ['{mail}, {name}'.format(**_account)] + + zf.writestr(_domain + '_' + _account_type + '.txt', '\n'.join(_content)) + + zf.writestr('domains.txt', '\n'.join(_content_domains)) + except Exception as e: + raise web.seeother('/domains?msg=%s' % web.urlquote(repr(e))) + finally: + zf.close() + + web.header('Content-Disposition', 'attachment; filename=accounts.zip') + return f.getvalue() + + +class ExportDomainAccounts: + @decorators.require_admin_login + def GET(self, domain): + domain = str(domain).lower() + mail = session.get('username') + + _wrap = SQLWrap() + conn = _wrap.conn + + if not sql_lib_general.is_domain_admin(domain=domain, admin=mail, conn=conn): + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + qr = sql_lib_general.export_managed_accounts(mail=mail, domains=[domain], conn=conn) + if not qr[0]: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + managed_domains = qr[1] + + _domains = [] + _total_domains = 0 + _total_users = 0 + _total_lists = 0 + _total_aliases = 0 + + for d in managed_domains: + _total_domains += 1 + _domains += [d['domain']] + _total_users += d['total_users'] + _total_lists += d['total_lists'] + _total_aliases += d['total_aliases'] + + # Generate zip file + f = io.BytesIO() + with zipfile.ZipFile(f, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: + # Summary of all managed accounts + content_summary = ['- Exported domains: %d' % _total_domains] + content_summary += ['- Mailboxes: %d' % _total_users] + content_summary += ['- Mailing lists: %d' % _total_lists] + content_summary += ['- Mail aliases: %d' % _total_aliases] + zf.writestr('summary.txt', '\n'.join(content_summary)) + + _content_domains = ['# All managed domains:'] + _content_domains += ['# Format: domain name, display name'] + + # Generate files for each domain + for d in managed_domains: + _domain = d['domain'] + _content_domains += ['{domain}, {name}'.format(**d)] + + for account_type in ['users', 'lists', 'aliases']: + if account_type == 'users': + _content = ['# Mailboxes under domain %s' % _domain] + elif account_type == 'lists': + _content = ['# Mailing lists under domain %s' % _domain] + else: + # account_type == 'aliases' + _content = ['# Mail aliases under domain %s' % _domain] + + _content += ['# Format: mail address, display name'] + + for _account in d[account_type]: + _content += ['{mail}, {name}'.format(**_account)] + + zf.writestr(_domain + '_' + account_type + '.txt', '\n'.join(_content)) + zf.writestr('domains.txt', '\n'.join(_content_domains)) + + web.header('Content-Disposition', 'attachment; filename=accounts.zip') + return f.getvalue() + + +class ExportAdminStatistics: + @decorators.require_global_admin + def GET(self): + """ + Admin + domain1.com | 12 Mailboxes | 3 Mailinglists + domain2.com | 9 Mailboxes | 1 Mailinglist + """ + _wrap = SQLWrap() + conn = _wrap.conn + + # Get all admins + qr = sql_lib_admin.get_all_admins(email_only=True, conn=conn) + + if not qr[0]: + return qr + + all_admins = qr[1] + + # Get all global admins + qr = sql_lib_admin.get_all_global_admins(conn=conn) + if not qr[0]: + return qr + + global_admins = qr[1] + non_global_admins = [i for i in all_admins if i not in global_admins] + + # dict used to store analyzed domain names to avoid duplicate ldap query: + # {'': {'user': 10, + # 'aliases': 23, + # 'maillists': 2}, ...} + _analyzed_domains = {} + + # dict used to store admin and managed domains. + # {'': [, , ...], ...} + # WARNING: it's possible that admin doesn't manage any domains. + _admins_and_domains = {} + + # Write statistics in csv file. + for _admin in non_global_admins: + _qr = sql_lib_admin.get_managed_domains(admin=_admin, + domain_name_only=True, + listed_only=True, + conn=conn) + + if _qr[0]: + _domains = _qr[1] + _admins_and_domains[_admin] = _domains + + for _domain in _domains: + if _domain not in _analyzed_domains: + _num_users = sql_lib_general.num_users_under_domain(domain=_domain, conn=conn) + _num_aliases = sql_lib_general.num_aliases_under_domain(domain=_domain, conn=conn) + _num_ml = sql_lib_general.num_maillists_under_domain(domain=_domain, conn=conn) + + _analyzed_domains[_domain] = {'users': _num_users, + 'aliases': _num_aliases, + 'maillists': _num_ml} + + _rows = [] + + for _admin in global_admins: + _rows.append([_admin, 'ALL']) + + for (_admin, _domains) in list(_admins_and_domains.items()): + _rows.append([_admin, len(_domains)]) + + _count = 1 + for _domain in _domains: + _num_users = _analyzed_domains[_domain]['users'] + _num_aliases = _analyzed_domains[_domain]['aliases'] + _num_maillists = _analyzed_domains[_domain]['maillists'] + + _rows.append([_count, _domain, _num_users, _num_aliases, _num_maillists]) + _count += 1 + + try: + f = io.StringIO() + cw = csv.writer(f) + + # Header row + cw.writerow(['Admin', 'Managed Domains', 'Users', 'Aliases', 'Mailing Lists']) + + # Data rows + cw.writerows(_rows) + + v = f.getvalue() + except Exception as e: + raise web.seeother('/domains?msg=%s' % web.urlquote(repr(e))) + + web.header('Content-Disposition', 'attachment; filename=statistics_admins.csv') + return v diff --git a/controllers/sql/ml.py b/controllers/sql/ml.py new file mode 100644 index 0000000..ed3e4eb --- /dev/null +++ b/controllers/sql/ml.py @@ -0,0 +1,376 @@ +# Author: Zhang Huangbin + +import web + +from libs import iredutils, form_utils + +from libs.sqllib import SQLWrap, decorators +from libs.sqllib import ml as sql_lib_ml +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import general as sql_lib_general +from libs.sqllib import utils as sql_lib_utils + + +session = web.config.get('_session') + + +class List: + @decorators.require_domain_access + def GET(self, domain, cur_page=1, disabled_only=False): + domain = str(domain).lower() + cur_page = int(cur_page) or 1 + + form = web.input(_unicode=False) + + all_first_chars = [] + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + total = sql_lib_ml.num_maillists_under_domain(conn=conn, + domain=domain, + disabled_only=disabled_only, + first_char=first_char) + + records = [] + if total: + _qr = sql_lib_general.get_first_char_of_all_accounts(domain=domain, + account_type='ml', + conn=conn) + if _qr[0]: + all_first_chars = _qr[1] + + qr = sql_lib_ml.get_basic_ml_profiles(conn=conn, + domain=domain, + page=cur_page, + first_char=first_char, + disabled_only=disabled_only) + if qr[0]: + records = qr[1] + + return web.render( + 'sql/ml/list.html', + cur_domain=domain, + cur_page=cur_page, + total=total, + maillists=records, + all_first_chars=all_first_chars, + first_char=first_char, + msg=form.get('msg', None), + ) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, domain): + form = web.input(_unicode=False, mail=[]) + domain = str(domain).lower() + + accounts = form.get('mail', []) + action = form.get('action', None) + msg = form.get('msg', None) + + # Filter aliases not under the same domain. + accounts = [str(v).lower() + for v in accounts + if iredutils.is_email(v) and str(v).endswith('@' + domain)] + + _wrap = SQLWrap() + conn = _wrap.conn + + if action == 'delete': + result = sql_lib_ml.delete_maillists(accounts=accounts, + keep_archive=True, + conn=conn) + msg = 'DELETED' + elif action == 'delete_without_archiving': + result = sql_lib_ml.delete_maillists(accounts=accounts, + keep_archive=False, + conn=conn) + msg = 'DELETED' + elif action == 'disable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='maillist', + enable_account=False) + msg = 'DISABLED' + elif action == 'enable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=accounts, + account_type='maillist', + enable_account=True) + msg = 'ENABLED' + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + raise web.seeother('/mls/{}?msg={}'.format(domain, msg)) + else: + raise web.seeother('/mls/{}?msg={}'.format(domain, web.urlquote(result[1]))) + + +class Create: + @decorators.require_domain_access + def GET(self, domain): + domain = str(domain).lower() + + form = web.input() + all_domains = [] + + # Get all domains, select the first one. + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True) + + if qr[0]: + all_domains = qr[1] + + # Get domain profile. + qr_profile = sql_lib_domain.simple_profile(domain=domain, conn=conn) + if qr_profile[0]: + domain_profile = qr_profile[1] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr_profile[1])) + + # Get total number and allocated quota size of existing users under domain. + num_maillists_under_domain = sql_lib_ml.num_maillists_under_domain(domain=domain, conn=conn) + + # TODO read default creation settings from domain profile. + # Default creation settings + default_creation_settings = {'only_subscriber_can_post': 'yes'} + + return web.render( + 'sql/ml/create.html', + cur_domain=domain, + allDomains=all_domains, + profile=domain_profile, + num_existing_maillists=num_maillists_under_domain, + default_creation_settings=default_creation_settings, + msg=form.get('msg'), + ) + + @decorators.require_domain_access + @decorators.csrf_protected + def POST(self, domain): + domain = str(domain).lower() + form = web.input() + + domain_in_form = form_utils.get_domain_name(form) + + if domain != domain_in_form: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + listname = form_utils.get_single_value(form, input_name='listname', to_string=True, to_lowercase=True) + mail = listname + '@' + domain + + qr = sql_lib_ml.add_ml_from_web_form(domain=domain, form=form) + + if qr[0]: + raise web.seeother('/profile/ml/general/%s?msg=CREATED' % mail) + else: + raise web.seeother('/create/ml/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + +class Profile: + @decorators.require_domain_access + def GET(self, profile_type, mail): + form = web.input() + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + + _wrap = SQLWrap() + conn = _wrap.conn + + # Get mlmmj account profile + qr = sql_lib_ml.get_profile(mail=mail, conn=conn) + if qr[0] is not True: + raise web.seeother('/mls/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + profile = qr[1] + + # Get per-account alias addresses. + qr = sql_lib_ml.get_alias_addresses(mail=mail, conn=conn) + if qr[0]: + alias_addresses = qr[1] + else: + raise web.seeother('/mls/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + # Get subscribers + subscribers = [] + + qr = sql_lib_ml.get_subscribers(mail=mail) + if qr[0]: + subscribers = qr[1] + + return web.render('sql/ml/profile.html', + cur_domain=domain, + mail=mail, + profile_type=profile_type, + profile=profile, + alias_addresses=alias_addresses, + subscribers=subscribers, + msg=form.get('msg')) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, profile_type, mail): + form = web.input(subscriber=[]) + + result = sql_lib_ml.update(mail=mail, + profile_type=profile_type, + form=form) + + if result[0]: + raise web.seeother('/profile/ml/{}/{}?msg=UPDATED'.format(profile_type, mail)) + else: + raise web.seeother('/profile/ml/{}/{}?msg={}'.format(profile_type, mail, web.urlquote(result[1]))) + + +class AddSubscribers: + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, mail): + form = web.input(_unicode=False) + _require_confirm = 'require_confirm' in form + + qr = sql_lib_ml.add_subscribers(mail=mail, form=form) + + if qr[0]: + if _require_confirm: + raise web.seeother('/profile/ml/members/%s?msg=CONFIRM_MAIL_SENT' % mail) + else: + raise web.seeother('/profile/ml/members/%s?msg=MEMBERS_ADDED' % mail) + else: + raise web.seeother('/profile/ml/members/{}?msg={}'.format(mail, web.urlquote(qr[1]))) + + +class MigrateAliasToML: + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, mail): + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + qr = sql_lib_ml.migrate_alias_to_ml(mail=mail) + + if qr[0]: + raise web.seeother('/profile/ml/general/%s?msg=MIGRATED' % mail) + else: + raise web.seeother('/aliases/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + +# self-service: allow user to manage lists as owner or moderator. +class ManagedMls: + @decorators.require_preference_access("manageml") + def GET(self, cur_page=1): + mail = session['username'] + cur_page = int(cur_page) or 1 + + form = web.input(_unicode=False) + + all_first_chars = [] + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + # Get managed mailing lists. + total = sql_lib_ml.num_maillists_managed_by_user(mail=mail, first_char=first_char, conn=conn) + + rows = [] + if total: + _qr = sql_lib_ml.get_first_char_of_all_managed_mls(mail=mail, conn=conn) + if _qr[0]: + all_first_chars = _qr[1] + + qr = sql_lib_ml.get_basic_profiles_of_managed_mls( + page=cur_page, + first_char=first_char, + conn=conn, + ) + if qr[0]: + rows = qr[1] + + return web.render( + 'sql/self-service/ml/list.html', + cur_page=cur_page, + total=total, + maillists=rows, + all_first_chars=all_first_chars, + first_char=first_char, + msg=form.get('msg', None), + ) + + +class ManagedMlProfile: + @decorators.require_preference_access("manageml") + @decorators.require_ml_owner_or_moderator + def GET(self, profile_type, mail): + form = web.input() + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + + _wrap = SQLWrap() + conn = _wrap.conn + + # Get account profile + qr = sql_lib_ml.get_profile(mail=mail, conn=conn) + if not qr[0]: + raise web.seeother('/mls/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + profile = qr[1] + + # Get subscribers + subscribers = [] + qr = sql_lib_ml.get_subscribers(mail=mail) + if qr[0]: + subscribers = qr[1] + + return web.render('sql/self-service/ml/profile.html', + mail=mail, + profile_type=profile_type, + profile=profile, + subscribers=subscribers, + msg=form.get('msg')) + + @decorators.require_preference_access("manageml") + @decorators.csrf_protected + @decorators.require_ml_owner_or_moderator + def POST(self, profile_type, mail): + form = web.input(subscriber=[]) + + qr = sql_lib_ml.update(mail=mail, + profile_type=profile_type, + form=form) + + if qr[0]: + raise web.seeother('/self-service/ml/profile/{}/{}?msg=UPDATED'.format(profile_type, mail)) + else: + raise web.seeother('/self-service/ml/profile/{}/{}?msg={}'.format(profile_type, mail, web.urlquote(qr[1]))) + + +# self-service +class ManagedMlAddSubscribers: + @decorators.require_preference_access("manageml") + @decorators.csrf_protected + @decorators.require_ml_owner_or_moderator + def POST(self, mail): + form = web.input(_unicode=False) + + qr = sql_lib_ml.add_subscribers(mail=mail, form=form) + + if qr[0]: + raise web.seeother('/self-service/ml/profile/members/%s?msg=MEMBERS_ADDED' % mail) + else: + raise web.seeother('/self-service/ml/profile/members/{}?msg={}'.format(mail, web.urlquote(qr[1]))) diff --git a/controllers/sql/urls.py b/controllers/sql/urls.py new file mode 100644 index 0000000..96e2d6c --- /dev/null +++ b/controllers/sql/urls.py @@ -0,0 +1,169 @@ +# Author: Zhang Huangbin + +import settings +from libs.regxes import email as e, domain as d + +# fmt: off +urls = [ + # Make url ending with or without '/' going to the same class. + '/(.*)/', 'controllers.utils.Redirect', + + '/', 'controllers.sql.basic.Login', + '/login', 'controllers.sql.basic.Login', + '/logout', 'controllers.sql.basic.Logout', + '/dashboard', 'controllers.sql.basic.Dashboard', + + # Search. + '/search', 'controllers.sql.basic.Search', + + # Perform some operations from search page. + '/action/(user|alias|ml)', 'controllers.sql.basic.OperationsFromSearchPage', + + # Export managed accounts + '/export/managed_accounts/(%s$)' % e, 'controllers.sql.export.ExportManagedAccounts', + '/export/statistics/admins', 'controllers.sql.export.ExportAdminStatistics', + '/export/domain/(%s$)' % d, 'controllers.sql.export.ExportDomainAccounts', + + # Domain related. + '/domains', 'controllers.sql.domain.List', + r'/domains/page/(\d+)', 'controllers.sql.domain.List', + # List disabled accounts. + '/domains/disabled', 'controllers.sql.domain.ListDisabled', + r'/domains/disabled/page/(\d+)', 'controllers.sql.domain.ListDisabled', + # Domain profiles + '/profile/domain/(general)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(aliases)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(relay)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(backupmx)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(bcc)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(catchall)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(throttle)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(greylisting)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(wblist)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(spampolicy)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(advanced)/(%s$)' % d, 'controllers.sql.domain.Profile', + '/profile/domain/(%s)' % d, 'controllers.sql.domain.Profile', + '/create/domain', 'controllers.sql.domain.Create', + + # Admin related. + '/admins', 'controllers.sql.admin.List', + r'/admins/page/(\d+)', 'controllers.sql.admin.List', + '/profile/admin/(general)/(%s$)' % e, 'controllers.sql.admin.Profile', + '/profile/admin/(password)/(%s$)' % e, 'controllers.sql.admin.Profile', + '/create/admin', 'controllers.sql.admin.Create', + + # Redirect to first mail domain. + '/create/(user|ml|alias)', 'controllers.sql.utils.CreateDispatcher', + + # User related. + '/users/(%s$)' % d, 'controllers.sql.user.List', + r'/users/(%s)/page/(\d+)' % d, 'controllers.sql.user.List', + # List disabled accounts. + '/users/(%s)/disabled' % d, 'controllers.sql.user.ListDisabled', + r'/users/(%s)/disabled/page/(\d+)' % d, 'controllers.sql.user.ListDisabled', + # List all last logins. + '/users/(%s)/last_logins' % d, 'controllers.sql.user.AllLastLogins', + # Create user. + '/create/user/(%s$)' % d, 'controllers.sql.user.Create', + # Profile pages. + '/profile/user/(general)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(forwarding)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(bcc)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(relay)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(aliases)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(wblist)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(spampolicy)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(password)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(throttle)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(greylisting)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(advanced)/(%s$)' % e, 'controllers.sql.user.Profile', + '/profile/user/(rename)/(%s$)' % e, 'controllers.sql.user.Profile', + + '/apiproxy/user/(%s$)' % e, 'controllers.sql.user.APIProxyUser', + #################### + # mlmmj mailing list + # + '/create/ml/(%s$)' % d, 'controllers.sql.ml.Create', + # make it compatible with old (LDAP) mailing list + '/create/maillist/(%s$)' % d, 'controllers.sql.ml.Create', + '/mls/(%s$)' % d, 'controllers.sql.ml.List', + r'/mls/(%s)/page/(\d+)' % d, 'controllers.sql.ml.List', + '/profile/ml/(general|aliases|owners|members|newsletter)/(%s$)' % e, 'controllers.sql.ml.Profile', + # Add subscribers + '/profile/ml/add_subscribers/(%s$)' % e, 'controllers.sql.ml.AddSubscribers', + # migrate alias account to mlmmj mailing list. + '/migrate/alias_to_ml/(%s$)' % e, 'controllers.sql.ml.MigrateAliasToML', + + # Alias related. + '/aliases', 'controllers.sql.alias.List', + '/aliases/(%s$)' % d, 'controllers.sql.alias.List', + r'/aliases/(%s)/page/(\d+)' % d, 'controllers.sql.alias.List', + # List disabled accounts. + '/aliases/(%s)/disabled' % d, 'controllers.sql.alias.ListDisabled', + r'/aliases/(%s)/disabled/page/(\d+)' % d, 'controllers.sql.alias.ListDisabled', + '/profile/alias/(general)/(%s$)' % e, 'controllers.sql.alias.Profile', + '/profile/alias/(members)/(%s$)' % e, 'controllers.sql.alias.Profile', + '/profile/alias/(rename)/(%s$)' % e, 'controllers.sql.alias.Profile', + '/create/alias/(%s$)' % d, 'controllers.sql.alias.Create', + + # User admins + '/admins/(%s$)' % d, 'controllers.sql.user.Admin', + r'/admins/(%s)/page/(\d+)' % d, 'controllers.sql.user.Admin', + + # + # Self-service + # + '/preferences', 'controllers.sql.user.Preferences', + '/preferences/(general)$', 'controllers.sql.user.Preferences', + '/preferences/(forwarding)$', 'controllers.sql.user.Preferences', + '/preferences/(password)$', 'controllers.sql.user.Preferences', + # manage owned or moderated mailing lists + '/self-service/mls', 'controllers.sql.ml.ManagedMls', + '/self-service/mls/page/(\d+)', 'controllers.sql.ml.ManagedMls', + '/self-service/ml/profile/(general|owners|members|newsletter)/(%s$)' % e, 'controllers.sql.ml.ManagedMlProfile', + '/self-service/ml/profile/add_subscribers/(%s$)' % e, 'controllers.sql.ml.ManagedMlAddSubscribers', +] + + +# API Interfaces +if settings.ENABLE_RESTFUL_API: + urls += [ + # API Interfaces + '/api/login', 'controllers.sql.basic.APILogin', + + # + # Domain + # + '/api/domains', 'controllers.sql.api_domain.APIDomains', + '/api/domain/(%s$)' % d, 'controllers.sql.api_domain.APIDomain', + # Delete domain, and keep mailboxes for given days + r'/api/domain/(%s)/keep_mailbox_days/(\d+)' % d, 'controllers.sql.api_domain.APIDomain', + '/api/domain/admins/(%s$)' % d, 'controllers.sql.api_domain.APIDomainAdmin', + + # User + '/api/user/(%s$)' % e, 'controllers.sql.api_user.APIUser', + # Delete user, and keep mailboxes for given days + r'/api/user/(%s)/keep_mailbox_days/(\d+)' % e, 'controllers.sql.api_user.APIUser', + '/api/user/({})/change_email/({}$)'.format(e, e), 'controllers.sql.api_user.APIChangeEmail', + '/api/users/(%s$)' % d, 'controllers.sql.api_user.APIUsers', + + # Alias + '/api/alias/(%s$)' % e, 'controllers.sql.api_alias.APIAlias', + '/api/alias/({})/change_email/({}$)'.format(e, e), 'controllers.sql.api_alias.APIChangeEmail', + '/api/aliases/(%s$)' % d, 'controllers.sql.api_alias.APIAliases', + + # (mlmmj) mailing list + '/api/mls/(%s$)' % d, 'controllers.sql.api_ml.APIMLS', + '/api/ml/(%s$)' % e, 'controllers.sql.api_ml.APIML', + + # Admin + '/api/admin/(%s$)' % e, 'controllers.sql.api_admin.APIAdmin', + + # + # Misc + # + # Verify account password. + '/api/verify_password/(user)/(%s$)' % e, 'controllers.sql.api_misc.APIVerifyPassword', + '/api/verify_password/(admin)/(%s$)' % e, 'controllers.sql.api_misc.APIVerifyPassword', + ] +# fmt: on diff --git a/controllers/sql/user.py b/controllers/sql/user.py new file mode 100644 index 0000000..6e5a89c --- /dev/null +++ b/controllers/sql/user.py @@ -0,0 +1,834 @@ +# Author: Zhang Huangbin + +import web +import settings + +from controllers.utils import api_render + +from libs import iredutils, form_utils +from libs.l10n import TIMEZONES + +from libs.sqllib import SQLWrap, decorators, sqlutils +from libs.sqllib import user as sql_lib_user +from libs.sqllib import alias as sql_lib_alias +from libs.sqllib import ml as sql_lib_ml +from libs.sqllib import admin as sql_lib_admin +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import utils as sql_lib_utils +from libs.sqllib import general as sql_lib_general +from libs import mlmmj + +from libs.amavisd import spampolicy as spampolicylib, wblist as lib_wblist + +session = web.config.get('_session') + +if settings.iredapd_enabled: + from libs.iredapd import throttle as iredapd_throttle + from libs.iredapd import greylist as iredapd_greylist + + +class List: + @decorators.require_domain_access + def GET(self, domain, cur_page=1, disabled_only=False): + domain = str(domain).lower() + cur_page = int(cur_page) or 1 + + form = web.input(_unicode=False) + order_name = form.get('order_name') + order_by_desc = (form.get('order_by', 'asc').lower() == 'desc') + + records = [] + + # Real-time used quota. + used_quotas = {} + # Last login date + last_logins = {} + + # Forwardings and per-user alias addresses + user_forwardings = {} + user_alias_addresses = {} + user_assigned_groups = {} + + all_first_chars = [] + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + total = sql_lib_user.num_users_under_domains(conn=conn, + domains=[domain], + disabled_only=disabled_only, + first_char=first_char) + + if total: + _qr = sql_lib_general.get_first_char_of_all_accounts(domain=domain, + account_type='user', + conn=conn) + if _qr[0]: + all_first_chars = _qr[1] + + qr = sql_lib_user.get_paged_users(conn=conn, + domain=domain, + cur_page=cur_page, + order_name=order_name, + order_by_desc=order_by_desc, + first_char=first_char, + disabled_only=disabled_only) + + if qr[0]: + records = qr[1] + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + # Get list of email addresses + mails = [] + for r in records: + mails += [str(r.get('username')).lower()] + + if mails: + # Get real-time mailbox usage + if settings.SHOW_USED_QUOTA: + try: + used_quotas = sql_lib_general.get_account_used_quota(accounts=mails, conn=conn) + except Exception: + pass + + # Get last login + last_logins = sql_lib_general.get_account_last_login(accounts=mails, conn=conn) + + # Get user forwardings + (_status, _result) = sql_lib_user.get_bulk_user_forwardings(conn=conn, mails=mails) + if _status: + user_forwardings = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + # Get user alias addresses + (_status, _result) = sql_lib_user.get_bulk_user_alias_addresses(mails=mails, conn=conn) + if _status: + user_alias_addresses = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + # Get assigned groups + (_status, _result) = sql_lib_user.get_bulk_user_assigned_groups(mails=mails, conn=conn) + if _status: + user_assigned_groups = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + if session.get('is_global_admin'): + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX_FOR_GLOBAL_ADMIN + else: + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX + + return web.render('sql/user/list.html', + cur_domain=domain, + cur_page=cur_page, + total=total, + users=records, + user_forwardings=user_forwardings, + user_alias_addresses=user_alias_addresses, + user_assigned_groups=user_assigned_groups, + used_quotas=used_quotas, + last_logins=last_logins, + order_name=order_name, + order_by_desc=order_by_desc, + all_first_chars=all_first_chars, + first_char=first_char, + disabled_only=disabled_only, + days_to_keep_removed_mailbox=days_to_keep_removed_mailbox, + msg=form.get('msg', None)) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, domain, page=1): + form = web.input(_unicode=False, mail=[]) + page = int(page) + if page < 1: + page = 1 + + domain = str(domain).lower() + + # Filter users not under the same domain. + mails = [str(v).strip().lower() for v in form.get("mail", [])] + mails = [v for v in mails if iredutils.is_email(v) and v.endswith('@' + domain)] + + action = form.get('action', None) + msg = form.get('msg', None) + + redirect_to_admin_list = False + if 'redirect_to_admin_list' in form: + redirect_to_admin_list = True + + _wrap = SQLWrap() + conn = _wrap.conn + + if action == 'delete': + keep_mailbox_days = form_utils.get_single_value(form=form, + input_name='keep_mailbox_days', + default_value=0, + is_integer=True) + result = sql_lib_user.delete_users(conn=conn, + accounts=mails, + keep_mailbox_days=keep_mailbox_days) + msg = 'DELETED' + elif action == 'disable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=mails, + account_type='user', + enable_account=False) + msg = 'DISABLED' + elif action == 'enable': + result = sql_lib_utils.set_account_status(conn=conn, + accounts=mails, + account_type='user', + enable_account=True) + msg = 'ENABLED' + elif action == 'markasadmin': + result = sql_lib_user.mark_user_as_admin(conn=conn, + domain=domain, + users=mails, + as_normal_admin=True) + msg = 'MARKASADMIN' + elif action == 'unmarkasadmin': + result = sql_lib_user.mark_user_as_admin(conn=conn, + domain=domain, + users=mails, + as_normal_admin=False) + msg = 'UNMARKASADMIN' + elif action == 'markasglobaladmin': + result = sql_lib_user.mark_user_as_admin(conn=conn, + domain=domain, + users=mails, + as_global_admin=True) + msg = 'MARKASGLOBALADMIN' + elif action == 'unmarkasglobaladmin': + result = sql_lib_user.mark_user_as_admin(conn=conn, + domain=domain, + users=mails, + as_global_admin=False) + msg = 'UNMARKASGLOBALADMIN' + else: + result = (False, 'INVALID_ACTION') + + if result[0]: + if redirect_to_admin_list: + raise web.seeother('/admins/%s/page/%d?msg=%s' % (domain, page, msg)) + else: + raise web.seeother('/users/%s/page/%d?msg=%s' % (domain, page, msg)) + else: + if redirect_to_admin_list: + raise web.seeother('/admins/%s/page/%d?msg=%s' % (domain, page, web.urlquote(result[1]))) + else: + raise web.seeother('/users/%s/page/%d?msg=%s' % (domain, page, web.urlquote(result[1]))) + + +class ListDisabled: + @decorators.require_domain_access + def GET(self, domain, cur_page=1): + _instance = List() + return _instance.GET(domain=domain, cur_page=cur_page, disabled_only=True) + + +class Profile: + # Don't use decorator `@decorators.require_domain_access` here, because if + # domain admin doesn't manage its own domain, it cannot access its own + # profile. + def GET(self, profile_type, mail): + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + + _wrap = SQLWrap() + conn = _wrap.conn + + # - Allow global admin + # - normal admin who manages this domain + # - allow normal admin who doesn't manage this domain, but is updating its own profile + if sql_lib_general.is_domain_admin(domain=domain, admin=session.get('username'), conn=conn) or \ + (session.get('is_normal_admin') and session.get('username') == mail): + pass + else: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + if profile_type == 'rename': + raise web.seeother('/profile/user/general/' + mail) + + form = web.input() + msg = form.get('msg', '') + + discarded_aliases = form.get('discarded_aliases', '') + if discarded_aliases: + discarded_aliases = [i.strip().lower() + for i in discarded_aliases.split(',')] + + # profile_type == 'general' + used_quota = {} + last_logins = {} + + # profile_type == 'greylisting' + # greylisting: iRedAPD + gl_setting = {} + gl_whitelists = [] + + # profile_type == 'throttle' + # throttle: iRedAPD + inbound_throttle_setting = {} + outbound_throttle_setting = {} + + # profile_type == 'advanced' + disabled_user_profiles = [] # Per-domain disabled user profiles. + + if mail.startswith('@') and iredutils.is_domain(domain): + # Catchall account. + raise web.seeother('/profile/domain/catchall/%s' % domain) + + qr = sql_lib_user.profile(mail=mail, conn=conn) + if qr[0]: + user_profile = qr[1] + + if not session.get('is_global_admin'): + sql_lib_user.redirect_if_user_is_global_admin(conn=conn, mail=mail, user_profile=user_profile) + else: + raise web.seeother('/users/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + del qr + + # Get mailbox.allow_nets + allow_nets = [] + _allow_nets = user_profile.get('allow_nets') + if _allow_nets: + allow_nets = _allow_nets.split(',') + + # Get per-user settings + user_settings = {} + qr = sql_lib_general.get_user_settings(conn=conn, + mail=mail, + existing_settings=user_profile['settings']) + if qr[0]: + user_settings = qr[1] + del qr + + # Get used quota. + if settings.SHOW_USED_QUOTA: + used_quota = sql_lib_general.get_account_used_quota(accounts=[mail], conn=conn) + + # Get last login. + last_logins = sql_lib_general.get_account_last_login(accounts=[mail], conn=conn) + + # Get basic profile of all mail alias accounts under same domain. + all_aliases = [] + (_status, _result) = sql_lib_alias.get_basic_alias_profiles(domain=domain, conn=conn) + if _status: + all_aliases = _result + + # Get email addresses of mail alias accounts which has current mail + # user as a member + assigned_aliases = [] + (_status, _result) = sql_lib_user.get_assigned_aliases(mail=mail, conn=conn) + if _status: + assigned_aliases = _result + + # Get per-user alias addresses. + user_alias_addresses = [] + qr = sql_lib_user.get_user_alias_addresses(mail=mail, conn=conn) + if qr[0]: + user_alias_addresses = qr[1] + + # subscribable mailing lists + all_maillist_addresses = [] + all_subscribed_lists = [] + + _qr = sql_lib_ml.get_basic_ml_profiles(domain=domain, + columns=['address', 'name'], + conn=conn) + if _qr[0]: + all_maillist_profiles = _qr[1] + for i in all_maillist_profiles: + all_maillist_addresses.append(i['address']) + else: + return _qr + + # Get subscribed mailing lists + _qr = mlmmj.get_subscribed_lists(mail=mail, query_all_lists=False) + if _qr[0]: + for i in _qr[1]: + all_subscribed_lists.append(i['mail']) + + # Get per-domain disabled user profiles. + qr = sql_lib_domain.simple_profile(conn=conn, + domain=domain, + columns=['settings']) + + if qr[0]: + domain_profile = qr[1] + domain_settings = sqlutils.account_settings_string_to_dict(domain_profile['settings']) + + disabled_user_profiles = domain_settings.get('disabled_user_profiles', []) + + db_settings = iredutils.get_settings_from_db() + _min_passwd_length = db_settings['min_passwd_length'] + _max_passwd_length = db_settings['max_passwd_length'] + + min_passwd_length = domain_settings.get('min_passwd_length', _min_passwd_length) + max_passwd_length = domain_settings.get('max_passwd_length', _max_passwd_length) + + # Get sender dependent relayhost + relayhost = '' + (_status, _result) = sql_lib_general.get_sender_relayhost(sender=mail, conn=conn) + if _status: + relayhost = _result + + if settings.iredapd_enabled: + # Greylisting + gl_setting = iredapd_greylist.get_greylist_setting(account=mail) + gl_whitelists = iredapd_greylist.get_greylist_whitelists(account=mail) + + # Throttling + inbound_throttle_setting = iredapd_throttle.get_throttle_setting(account=mail, inout_type='inbound') + outbound_throttle_setting = iredapd_throttle.get_throttle_setting(account=mail, inout_type='outbound') + + # Get managed domains and all domains under control. + managed_domains = [] + all_domains = [] + + if session.get('is_global_admin') or session.get('is_normal_admin') or session.get('allowed_to_grant_admin'): + qr = sql_lib_admin.get_managed_domains(admin=mail, + domain_name_only=True, + listed_only=True, + conn=conn) + if qr[0]: + managed_domains += qr[1] + + if session.get('is_global_admin'): + qr = sql_lib_domain.get_all_domains(conn=conn, + columns=['domain', 'description']) + if qr[0]: + all_domains = qr[1] + else: + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.username, + listed_only=True) + if qr[0]: + all_domains = qr[1] + + # Get spam policy + spampolicy = {} + global_spam_score = None + if settings.amavisd_enable_policy_lookup: + qr = spampolicylib.get_spam_policy(account=mail) + if not qr[0]: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + else: + spampolicy = qr[1] + + global_spam_score = spampolicylib.get_global_spam_score() + + # Get per-user white/blacklists + whitelists = [] + blacklists = [] + outbound_whitelists = [] + outbound_blacklists = [] + + qr = lib_wblist.get_wblist(account=mail) + + if qr[0]: + whitelists = qr[1]['inbound_whitelists'] + blacklists = qr[1]['inbound_blacklists'] + outbound_whitelists = qr[1]['outbound_whitelists'] + outbound_blacklists = qr[1]['outbound_blacklists'] + + return web.render( + 'sql/user/profile.html', + cur_domain=domain, + mail=mail, + profile_type=profile_type, + profile=user_profile, + timezones=TIMEZONES, + min_passwd_length=min_passwd_length, + max_passwd_length=max_passwd_length, + store_password_in_plain_text=settings.STORE_PASSWORD_IN_PLAIN_TEXT, + password_policies=iredutils.get_password_policies(), + user_settings=user_settings, + used_quota=used_quota, + last_logins=last_logins, + all_aliases=all_aliases, + assigned_aliases=assigned_aliases, + user_alias_addresses=user_alias_addresses, + user_alias_cross_all_domains=settings.USER_ALIAS_CROSS_ALL_DOMAINS, + all_maillist_profiles=all_maillist_profiles, + all_subscribed_lists=all_subscribed_lists, + disabled_user_profiles=disabled_user_profiles, + allow_nets=allow_nets, + managed_domains=managed_domains, + all_domains=all_domains, + relayhost=relayhost, + # iRedAPD + gl_setting=gl_setting, + gl_whitelists=gl_whitelists, + # iRedAPD + inbound_throttle_setting=inbound_throttle_setting, + outbound_throttle_setting=outbound_throttle_setting, + # spam policy, wblist, throttling + spampolicy=spampolicy, + custom_ban_rules=settings.AMAVISD_BAN_RULES, + global_spam_score=global_spam_score, + whitelists=whitelists, + blacklists=blacklists, + outbound_whitelists=outbound_whitelists, + outbound_blacklists=outbound_blacklists, + languagemaps=iredutils.get_language_maps(), + msg=msg, + discarded_aliases=discarded_aliases, + ) + + # Don't use decorator `@decorators.require_domain_access` here, because if + # domain admin doesn't manage its own domain, it cannot access its own + # profile. + @decorators.csrf_protected + def POST(self, profile_type, mail): + form = web.input( + enabledService=[], + shadowAddress=[], + telephoneNumber=[], + subscribed_list=[], + memberOfGroup=[], + oldMemberOfAlias=[], + memberOfAlias=[], + domainName=[], # Managed domains + banned_rulenames=[], + ) + + mail = str(mail).lower() + domain = mail.split('@', 1)[-1] + + _wrap = SQLWrap() + conn = _wrap.conn + + # - Allow global admin + # - normal admin who manages this domain + # - allow normal admin who doesn't manage this domain, but is updating its own profile + if sql_lib_general.is_domain_admin(domain=domain, admin=session.get('username'), conn=conn) or \ + (session.get('is_normal_admin') and session.get('username') == mail): + pass + else: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + result = sql_lib_user.update(conn=conn, + mail=mail, + profile_type=profile_type, + form=form) + + if profile_type == 'rename': + profile_type = 'general' + + if result[0]: + _discarded_aliases = [] + if profile_type == 'aliases': + # Notify admin the discarded addresses. + try: + _discarded_aliases = result[1]['discarded_aliases'] + except: + pass + + if _discarded_aliases: + raise web.seeother('/profile/user/%s/%s?msg=UPDATED' + '&discarded_aliases=%s' % (profile_type, mail, ','.join(_discarded_aliases))) + else: + raise web.seeother('/profile/user/{}/{}?msg=UPDATED'.format(profile_type, mail)) + else: + raise web.seeother('/profile/user/{}/{}?msg={}'.format(profile_type, mail, web.urlquote(result[1]))) + + +class Create: + @decorators.require_domain_access + def GET(self, domain): + domain = str(domain).lower() + + form = web.input() + + # Get all managed domains. + _wrap = SQLWrap() + conn = _wrap.conn + + if session.get('is_global_admin'): + qr = sql_lib_domain.get_all_domains(conn=conn, name_only=True) + else: + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True) + + if qr[0]: + all_domains = qr[1] + else: + raise web.seeother('/domains?msg=' + web.urlquote(qr[1])) + + if not all_domains: + raise web.seeother('/domains?msg=NO_DOMAIN_AVAILABLE') + + # Get domain profile. + qr_profile = sql_lib_domain.simple_profile(domain=domain, conn=conn) + if qr_profile[0]: + domain_profile = qr_profile[1] + domain_settings = sqlutils.account_settings_string_to_dict(domain_profile['settings']) + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr_profile[1])) + + # Cet total number and allocated quota size of existing users under domain. + num_users_under_domain = sql_lib_general.num_users_under_domain(domain=domain, conn=conn) + used_quota_size = sql_lib_domain.get_allocated_domain_quota(domains=[domain], conn=conn) + + db_settings = iredutils.get_settings_from_db() + _min_passwd_length = db_settings['min_passwd_length'] + _max_passwd_length = db_settings['max_passwd_length'] + + min_passwd_length = domain_settings.get('min_passwd_length', _min_passwd_length) + max_passwd_length = domain_settings.get('max_passwd_length', _max_passwd_length) + + return web.render( + 'sql/user/create.html', + cur_domain=domain, + all_domains=all_domains, + profile=domain_profile, + domain_settings=domain_settings, + min_passwd_length=min_passwd_length, + max_passwd_length=max_passwd_length, + store_password_in_plain_text=settings.STORE_PASSWORD_IN_PLAIN_TEXT, + num_existing_users=num_users_under_domain, + usedQuotaSize=used_quota_size, + languagemaps=iredutils.get_language_maps(), + password_policies=iredutils.get_password_policies(), + msg=form.get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_domain_access + def POST(self, domain): + domain = str(domain).lower() + form = web.input() + + domain_in_form = form_utils.get_domain_name(form) + if domain != domain_in_form: + raise web.seeother('/domains?msg=PERMISSION_DENIED') + + # Get domain name, username, cn. + username = form_utils.get_single_value(form, + input_name='username', + to_string=True) + + qr = sql_lib_user.add_user_from_form(domain=domain, form=form) + + if qr[0]: + raise web.seeother('/profile/user/general/{}@{}?msg=CREATED'.format(username, domain)) + else: + raise web.seeother('/create/user/{}?msg={}'.format(domain, web.urlquote(qr[1]))) + + +# Internal domain admins +class Admin: + @decorators.require_domain_access + def GET(self, domain, cur_page=1): + domain = str(domain).lower() + cur_page = int(cur_page) or 1 + + form = web.input(_unicode=False) + + first_char = None + if 'starts_with' in form: + first_char = form.get('starts_with')[:1].upper() + if not iredutils.is_valid_account_first_char(first_char): + first_char = None + + _wrap = SQLWrap() + conn = _wrap.conn + + _include_global_admins = settings.SHOW_GLOBAL_ADMINS_IN_PER_DOMAIN_ADMIN_LIST + qr = sql_lib_admin.get_paged_domain_admins(conn=conn, + domain=domain, + include_global_admins=_include_global_admins, + current_page=cur_page, + first_char=first_char) + + if not qr[0]: + raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1])) + + total = qr[1]['total'] + records = qr[1]['records'] + + # Get list of email addresses + mails = [] + for r in records: + mails += [str(r.get('username'))] + + # Get real-time used quota. + used_quotas = {} + + if settings.SHOW_USED_QUOTA: + if mails: + try: + used_quotas = sql_lib_general.get_account_used_quota(accounts=mails, conn=conn) + except Exception: + pass + + # Get user forwardings + _status, _result = sql_lib_user.get_bulk_user_forwardings(conn=conn, mails=mails) + if _status: + user_forwardings = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + # Get user alias addresses + (_status, _result) = sql_lib_user.get_bulk_user_alias_addresses(mails=mails, conn=conn) + if _status: + user_alias_addresses = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + # Get assigned groups + (_status, _result) = sql_lib_user.get_bulk_user_assigned_groups(mails=mails, conn=conn) + if _status: + user_assigned_groups = _result + else: + raise web.seeother('/domains?msg=%s' % web.urlquote(_result)) + + if session.get('is_global_admin'): + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX_FOR_GLOBAL_ADMIN + else: + days_to_keep_removed_mailbox = settings.DAYS_TO_KEEP_REMOVED_MAILBOX + + return web.render('sql/user/list.html', + cur_domain=domain, + cur_page=cur_page, + total=total, + users=records, + user_forwardings=user_forwardings, + user_alias_addresses=user_alias_addresses, + user_assigned_groups=user_assigned_groups, + used_quotas=used_quotas, + first_char=first_char, + days_to_keep_removed_mailbox=days_to_keep_removed_mailbox, + all_are_admins=True, + msg=web.input().get('msg', None)) + + +# Preferences allowed to be updated by user +class Preferences: + @decorators.require_user_login + def GET(self, profile_type='general'): + form = web.input() + mail = session['username'] + domain = mail.split('@', 1)[-1] + + _wrap = SQLWrap() + conn = _wrap.conn + + qr = sql_lib_user.profile(mail=mail, conn=conn) + user_profile = qr[1] + del qr + + # Get per-user settings + user_settings = {} + qr = sql_lib_general.get_user_settings(conn=conn, + mail=mail, + existing_settings=user_profile['settings']) + if qr[0]: + user_settings = qr[1] + del qr + + # Get used quota + used_quota_bytes = 0 + if settings.SHOW_USED_QUOTA: + used_quota = sql_lib_general.get_account_used_quota(accounts=[mail], conn=conn) + + used_quota_bytes = used_quota.get(mail, {}).get('bytes', 0) + + # Get per-domain disabled user preferences. + qr = sql_lib_domain.simple_profile(conn=conn, + domain=domain, + columns=['settings']) + + if qr[0]: + domain_profile = qr[1] + domain_settings = sqlutils.account_settings_string_to_dict(domain_profile['settings']) + + disabled_user_preferences = domain_settings.get('disabled_user_preferences', []) + session['disabled_user_preferences'] = disabled_user_preferences + + db_settings = iredutils.get_settings_from_db() + _min_passwd_length = db_settings['min_passwd_length'] + _max_passwd_length = db_settings['max_passwd_length'] + + min_passwd_length = domain_settings.get('min_passwd_length', _min_passwd_length) + max_passwd_length = domain_settings.get('max_passwd_length', _max_passwd_length) + + password_policies = iredutils.get_password_policies() + if min_passwd_length > 0: + password_policies['min_passwd_length'] = min_passwd_length + + if max_passwd_length > 0: + password_policies['max_passwd_length'] = max_passwd_length + + return web.render( + 'sql/self-service/user/preferences.html', + cur_domain=domain, + mail=mail, + profile_type=profile_type, + profile=user_profile, + user_settings=user_settings, + used_quota_bytes=used_quota_bytes, + disabled_user_preferences=disabled_user_preferences, + languagemaps=iredutils.get_language_maps(), + timezones=TIMEZONES, + min_passwd_length=min_passwd_length, + max_passwd_length=max_passwd_length, + store_password_in_plain_text=settings.STORE_PASSWORD_IN_PLAIN_TEXT, + password_policies=password_policies, + msg=form.get('msg'), + ) + + @decorators.csrf_protected + @decorators.require_user_login + def POST(self, profile_type='general'): + mail = session['username'] + + form = web.input(telephoneNumber=[]) + + _wrap = SQLWrap() + conn = _wrap.conn + + result = sql_lib_user.update_preferences(conn=conn, + mail=mail, + form=form, + profile_type=profile_type) + + if result[0]: + raise web.seeother('/preferences?msg=UPDATED') + else: + raise web.seeother('/preferences?msg=%s' % web.urlquote(result[1])) + + +# APIProxyUser proxies requests to RESTful API interface without calling +# the exposed `/api/` url. +class APIProxyUser: + @decorators.require_domain_access + def PUT(self, mail): + form = web.input() + qr = sql_lib_user.api_update_profile(mail=mail, form=form, conn=None) + return api_render(qr) + + +class AllLastLogins: + @decorators.require_domain_access + def GET(self, domain): + domain = domain.lower() + last_logins = sql_lib_general.get_all_last_logins(domain=domain, conn=None) + + return web.render( + 'sql/user/all_last_logins.html', + cur_domain=domain, + last_logins=last_logins, + # msg=msg, + ) diff --git a/controllers/sql/utils.py b/controllers/sql/utils.py new file mode 100644 index 0000000..199b620 --- /dev/null +++ b/controllers/sql/utils.py @@ -0,0 +1,33 @@ +import web +from controllers.decorators import require_admin_login +from libs.sqllib import SQLWrap +from libs.sqllib import domain as sql_lib_domain +from libs.sqllib import admin as sql_lib_admin + +session = web.config.get('_session') + + +# Get all domains, select the first one. +class CreateDispatcher: + @require_admin_login + def GET(self, account_type): + _wrap = SQLWrap() + conn = _wrap.conn + + if session.get('is_global_admin'): + qr = sql_lib_domain.get_all_domains(conn=conn, name_only=True) + else: + qr = sql_lib_admin.get_managed_domains(conn=conn, + admin=session.get('username'), + domain_name_only=True) + + if qr[0]: + all_domains = qr[1] + + # Go to first available domain. + if all_domains: + raise web.seeother('/create/{}/{}'.format(account_type, all_domains[0])) + else: + raise web.seeother('/domains?msg=NO_DOMAIN_AVAILABLE') + else: + raise web.seeother('/domains?msg=' + web.urlquote(qr[1])) diff --git a/controllers/utils.py b/controllers/utils.py new file mode 100644 index 0000000..8a76d81 --- /dev/null +++ b/controllers/utils.py @@ -0,0 +1,67 @@ +# Author: Zhang Huangbin + +import simplejson as json +import web + + +class Redirect: + """Make url ending with or without '/' going to the same class.""" + + def GET(self, path): + raise web.seeother("/" + str(path)) + + +class Expired: + def GET(self): + web.header("Content-Type", "text/html") + return """ + + + + + License expired + + + +

Your license of iRedAdmin-Pro expired, please purchase a new license to continue using iRedAdmin-Pro.

+ + +""" + + +def _render_json(d): + web.header("Content-Type", "application/json") + return json.dumps(d) + + +def api_render(data): + """Convert given data to a dict and render it. + + - if `data` is a dict, return it directly. + - if `data` is a tuple: + - (True, ) -> {'_success': True} + - (True, xxx) -> {'_success': True, '_data': xxx} + - (False, ) -> {'_success': False} + - (False, xxx) -> {'_success': False, '_msg': xxx} + - if `data` is a boolean value (True, False), return {'_success': } + """ + if isinstance(data, dict): + d = data + elif isinstance(data, tuple): + if data[0] is True: + if len(data) == 2: + d = {"_success": True, "_data": data[1]} + else: + d = {"_success": True} + else: + if len(data) == 2: + d = {"_success": False, "_msg": data[1]} + else: + d = {"_success": False} + + elif isinstance(data, bool): + d = {"_success": data} + else: + d = {"_success": False, "_msg": repr(data)} + + return _render_json(d) diff --git a/docs/README.customization b/docs/README.customization new file mode 100644 index 0000000..fb071a0 --- /dev/null +++ b/docs/README.customization @@ -0,0 +1,15 @@ +* Logo image, favicon.ico, brand name and short description can be defined in + config file (settings.py): + +``` +BRAND_LOGO = 'logo.png' # load file 'static/logo.png' +BRAND_FAVICON = 'favicon.ico' # load file 'static/favicon.ico' +BRAND_NAME = 'iRedAdmin-Pro' +BRAND_DESC = 'iRedMail Admin Panel' +``` + +* Link of support page on page footer can be defined in config file (settings.py): + +``` +URL_SUPPORT = 'http://www.iredmail.org/support.html' +``` diff --git a/docs/tests.md b/docs/tests.md new file mode 100644 index 0000000..96fd0c3 --- /dev/null +++ b/docs/tests.md @@ -0,0 +1,7 @@ +# Perform unit tests + +## docstring tests + +``` +python3 -m doctest path/to/file.py +``` diff --git a/static/default/css/fancybox.css b/static/default/css/fancybox.css new file mode 100644 index 0000000..65e8b89 --- /dev/null +++ b/static/default/css/fancybox.css @@ -0,0 +1,360 @@ +/* + * FancyBox - jQuery Plugin + * Simple and fancy lightbox alternative + * + * Examples and documentation at: http://fancybox.net + * + * Copyright (c) 2008 - 2010 Janis Skarnelis + * That said, it is hardly a one-person project. Many people have submitted bugs, code, and offered their advice freely. Their support is greatly appreciated. + * + * Version: 1.3.4 (11/11/2010) + * Requires: jQuery v1.3+ + * + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html + */ + +#fancybox-loading { + position: fixed; + top: 50%; + left: 50%; + width: 40px; + height: 40px; + margin-top: -20px; + margin-left: -20px; + cursor: pointer; + overflow: hidden; + z-index: 1104; + display: none; +} + +#fancybox-loading div { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 480px; + background-image: url('../images/fancybox/fancybox.png'); +} + +#fancybox-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 1100; + display: none; +} + +#fancybox-tmp { + padding: 0; + margin: 0; + border: 0; + overflow: auto; + display: none; +} + +#fancybox-wrap { + position: absolute; + top: 0; + left: 0; + padding: 20px; + z-index: 1101; + outline: none; + display: none; +} + +#fancybox-outer { + position: relative; + width: 100%; + height: 100%; + background: #fff; +} + +#fancybox-content { + width: 0; + height: 0; + padding: 0; + outline: none; + position: relative; + overflow: hidden; + z-index: 1102; + border: 0px solid #fff; + text-align: left; +} + +#fancybox-hide-sel-frame { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + z-index: 1101; +} + +#fancybox-close { + position: absolute; + top: -15px; + right: -15px; + width: 30px; + height: 30px; + background: transparent url('../images/fancybox/fancybox.png') -40px 0px; + cursor: pointer; + z-index: 1103; + display: none; +} + +#fancybox-error { + color: #444; + font: normal 12px/20px Arial; + padding: 14px; + margin: 0; +} + +#fancybox-img { + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: none; + outline: none; + line-height: 0; + vertical-align: top; +} + +#fancybox-frame { + width: 100%; + height: 100%; + border: none; + display: block; +} + +#fancybox-left, #fancybox-right { + position: absolute; + bottom: 0px; + height: 100%; + width: 35%; + cursor: pointer; + outline: none; + background: transparent url('../images/fancybox/fancybox.blank.gif'); + z-index: 1102; + display: none; +} + +#fancybox-left { + left: 0px; +} + +#fancybox-right { + right: 0px; +} + +#fancybox-left-ico, #fancybox-right-ico { + position: absolute; + top: 50%; + left: -9999px; + width: 30px; + height: 30px; + margin-top: -15px; + cursor: pointer; + z-index: 1102; + display: block; +} + +#fancybox-left-ico { + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -30px; +} + +#fancybox-right-ico { + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -60px; +} + +#fancybox-left:hover, #fancybox-right:hover { + visibility: visible; /* IE6 */ +} + +#fancybox-left:hover span { + left: 20px; +} + +#fancybox-right:hover span { + left: auto; + right: 20px; +} + +.fancybox-bg { + position: absolute; + padding: 0; + margin: 0; + border: 0; + width: 20px; + height: 20px; + z-index: 1001; +} + +#fancybox-bg-n { + top: -20px; + left: 0; + width: 100%; + background-image: url('../images/fancybox/fancybox-x.png'); +} + +#fancybox-bg-ne { + top: -20px; + right: -20px; + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -162px; +} + +#fancybox-bg-e { + top: 0; + right: -20px; + height: 100%; + background-image: url('../images/fancybox/fancybox-y.png'); + background-position: -20px 0px; +} + +#fancybox-bg-se { + bottom: -20px; + right: -20px; + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -182px; +} + +#fancybox-bg-s { + bottom: -20px; + left: 0; + width: 100%; + background-image: url('../images/fancybox/fancybox-x.png'); + background-position: 0px -20px; +} + +#fancybox-bg-sw { + bottom: -20px; + left: -20px; + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -142px; +} + +#fancybox-bg-w { + top: 0; + left: -20px; + height: 100%; + background-image: url('../images/fancybox/fancybox-y.png'); +} + +#fancybox-bg-nw { + top: -20px; + left: -20px; + background-image: url('../images/fancybox/fancybox.png'); + background-position: -40px -122px; +} + +#fancybox-title { + font-family: Helvetica; + font-size: 12px; + z-index: 1102; +} + +.fancybox-title-inside { + padding-bottom: 10px; + text-align: center; + color: #333; + background: #fff; + position: relative; +} + +.fancybox-title-outside { + padding-top: 10px; + color: #fff; +} + +.fancybox-title-over { + position: absolute; + bottom: 0; + left: 0; + color: #FFF; + text-align: left; +} + +#fancybox-title-over { + padding: 10px; + background-image: url('fancy_title_over.png'); + display: block; +} + +.fancybox-title-float { + position: absolute; + left: 0; + bottom: -20px; + height: 32px; +} + +#fancybox-title-float-wrap { + border: none; + border-collapse: collapse; + width: auto; +} + +#fancybox-title-float-wrap td { + border: none; + white-space: nowrap; +} + +#fancybox-title-float-left { + padding: 0 0 0 15px; + background: url('../images/fancybox/fancybox.png') -40px -90px no-repeat; +} + +#fancybox-title-float-main { + color: #FFF; + line-height: 29px; + font-weight: bold; + padding: 0 0 3px 0; + background: url('../images/fancybox/fancybox-x.png') 0px -40px; +} + +#fancybox-title-float-right { + padding: 0 0 0 15px; + background: url('../images/fancybox/fancybox.png') -55px -90px no-repeat; +} + +/* IE6 */ + +.fancybox-ie6 #fancybox-close { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_close.png', sizingMethod='scale'); } + +.fancybox-ie6 #fancybox-left-ico { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_nav_left.png', sizingMethod='scale'); } +.fancybox-ie6 #fancybox-right-ico { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_nav_right.png', sizingMethod='scale'); } + +.fancybox-ie6 #fancybox-title-over { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_title_over.png', sizingMethod='scale'); zoom: 1; } +.fancybox-ie6 #fancybox-title-float-left { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_title_left.png', sizingMethod='scale'); } +.fancybox-ie6 #fancybox-title-float-main { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_title_main.png', sizingMethod='scale'); } +.fancybox-ie6 #fancybox-title-float-right { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_title_right.png', sizingMethod='scale'); } + +.fancybox-ie6 #fancybox-bg-w, .fancybox-ie6 #fancybox-bg-e, .fancybox-ie6 #fancybox-left, .fancybox-ie6 #fancybox-right, #fancybox-hide-sel-frame { + height: expression(this.parentNode.clientHeight + "px"); +} + +#fancybox-loading.fancybox-ie6 { + position: absolute; margin-top: 0; + top: expression( (-20 + (document.documentElement.clientHeight ? document.documentElement.clientHeight/2 : document.body.clientHeight/2 ) + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop )) + 'px'); +} + +#fancybox-loading.fancybox-ie6 div { background: transparent; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_loading.png', sizingMethod='scale'); } + +/* IE6, IE7, IE8 */ + +.fancybox-ie .fancybox-bg { background: transparent !important; } + +.fancybox-ie #fancybox-bg-n { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_n.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-ne { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_ne.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-e { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_e.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-se { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_se.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-s { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_s.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-sw { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_sw.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-w { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_w.png', sizingMethod='scale'); } +.fancybox-ie #fancybox-bg-nw { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='fancybox/fancy_shadow_nw.png', sizingMethod='scale'); } diff --git a/static/default/css/reset.css b/static/default/css/reset.css new file mode 100644 index 0000000..10e2f5c --- /dev/null +++ b/static/default/css/reset.css @@ -0,0 +1,53 @@ +/* Eric Meyer's CSS Reset v1.0 | 20080212 */ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, font, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 13px; + vertical-align: baseline; + background: transparent; +} +table, tbody, tfoot, thead, tr, th, td { + vertical-align: top; +} + +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} + +/* remember to define focus styles! */ +:focus { + outline: 0; +} + +/* remember to highlight inserts somehow! */ +ins { + text-decoration: none; +} +del { + text-decoration: line-through; +} + +/* tables still need 'cellspacing="0"' in the markup */ +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/static/default/css/screen.css b/static/default/css/screen.css new file mode 100644 index 0000000..25923c8 --- /dev/null +++ b/static/default/css/screen.css @@ -0,0 +1,3372 @@ +:root { + --accent-color: #810E97; + --accent-color-bright: #9115A7; +} + +html { + background: #333333; +} + +body { + min-width: 1000px; + text-align: center; + margin: 0px; + font-family: 'PT Sans', 'Trebuchet MS', arial; + /*color: #666666; */ + background: #222222; +} + +body.login { + background: none; + padding-top: 100px; + background: url('../images/header.png') top left repeat-x; +} + + +.clear:after { + content: "."; + display: block; + height: 0; + overflow: hidden; + clear: both; + visibility: hidden; +} + +.clean-margin { + margin: 0px !important; +} + +.clean-padding { + padding: 0px !important; +} + +ul.standard.clean-padding { + padding: 0 0 0 16px !important; +} + +ol.standard.clean-padding { + padding: 0 0 0 22px !important; +} + +.hidden { + display: none; +} + +.display { + display: block; +} + +.half { + width: 48% !important; +} + +.trio { + width: 31% !important; +} + +.quad { + width: 23% !important; +} + +.full { + width: 100%; +} + +.size-80 { + width: 80px; +} + +.size-120 { + width: 120px; +} + +.size-150 { + width: 150px; +} + +.size-170 { + width: 170px; +} + +.size-200 { + width: 200px; +} + +.fl { + float: left; +} + +.fr { + float: right; +} + +.fl-space { + float: left; + margin-right: 5px; +} + +.fr-space { + float: right; + margin-left: 5px; +} + +.fl-space2 { + float: left; + margin-right: 10px; +} + +.fr-space2 { + float: right; + margin-left: 10px; +} + +.bt-space0 { + margin-bottom: 0px !important; +} + +.bt-space5 { + margin-bottom: 5px !important; +} + +.bt-space10 { + margin-bottom: 10px !important; +} + +.bt-space15 { + margin-bottom: 15px !important; +} + +.bt-space20 { + margin-bottom: 20px !important; +} + +.bt-space30 { + margin-bottom: 30px !important; +} + +.bt-space40 { + margin-bottom: 40px !important; +} + +.ln-normal { + line-height: normal !important; +} + +.ln-22 { + line-height: 22px !important; +} + +.left { + text-align: left !important; +} + +.center { + text-align: center !important; + margin: auto !important; +} + +.right { + text-align: right !important; +} + +.block { + text-align: justify; +} + +img.block { + display: block; +} + +a { + color: #00A5C4; + text-decoration: none; +} + +a:hover { + color: #00A5C4; + text-decoration: underline; +} + +.button { + display: inline-block; + line-height: 16px; + border-width: 0px; + font-family: 'PT Sans', 'Trebuchet MS', arial; + color: #FFFFFF; + font-weight: bold; + cursor: pointer; + /*background: url('../images/button_glas1.png') center center repeat-x var(--accent-color);*/ + background-color: var(--accent-color-bright); + padding: 5px 13px 5px 13px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; + text-align: center; +} + +input.button { + display: inline-block; + line-height: 16px; + /* IE8 hack */ + line-height: 16px; + border-width: 0px; + color: #FFFFFF; + font-weight: bold; + cursor: pointer; + /*background: url('../images/button_glas1.png') center center repeat-x var(--accent-color);*/ + background-color: var(--accent-color-bright); + padding: 3px 10px 3px 10px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.button:hover { + color: #FFFFFF; + text-decoration: none; +} + +.button.green { + background-color: var(--accent-color); +} + +.button.red { + background-color: #D80017; +} + +.button.blue { + background-color: #00A5C4; +} + +.button.grey { + background-color: #BBBBBB; +} + +strong { + color: #333333; +} + +small { + line-height: 14px; + display: block; +} + +code { + color: #333333; + font-family: "Courier New", Courier, monospace, sans-serif; +} + +q { + background: url("../images/quote.png") no-repeat 7px 8px #FFFFFF; + color: #333333; + display: block; + font-size: 14px; + line-height: 18px; + font-style: normal; + min-height: 42px; + padding: 10px 10px 10px 32px; + quotes: "" ""; + font-style: italic; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +q cite { + color: #959595; + display: block; + font-size: 12px; + padding-top: 5px; + font-family: 'Trebuchet MS', arial; +} + +del { + color: #FF001C; + text-decoration: line-through; +} + +/* +.rule { padding-top: 2px; padding-bottom: 20px; background: url('../images/rule.gif') 0px 0px repeat-x; } +*/ +.rule2 { + padding-top: 4px; + padding-bottom: 20px; + background: url('../images/rule2.gif') 0px 0px repeat-x; +} + +.sidebar .rule { + padding-bottom: 15px; +} + +.cr-help { + cursor: help; +} + + +/***************************/ +/********** LOGIN **********/ +/***************************/ + +.login-box { + width: 550px; + margin: 0px auto; + margin-bottom: 30px; + background: url('../images/bck_white_10.png'); + border: 1px solid #666666; + text-align: left; + padding: 10px; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; + border-radius: 10px; +} + +.login-border { + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +.login-style { + border: 2px solid #FFFFFF; + background: url('../images/login.jpg') center center no-repeat; + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + border-radius: 4px; +} + +.login-header { + height: 71px; + background: url('../images/login_header.png') left bottom no-repeat; + padding: 15px 15px 0px 15px; + -moz-border-radius: 3px 3px 0px 0px; + -webkit-border-radius: 3px 3px 0px 0px; + border-radius: 3px 3px 0px 0px; +} + +.login-header .logo { + width: auto; + margin: 0px; + padding-top: 0px; +} + +.login-header .logo .title {} + +.login-header .logo .text { + color: #333333; +} + +.login-inside { + height: 195px; + padding-top: 35px; + /*border-bottom: 1px solid #bbbbbb;*/ + background: url('../images/gear.png') 325px bottom no-repeat; +} + +.login-inside p { + text-align: center; + padding-bottom: 10px; +} + +.login-data { + width: 290px; + padding: 30px 10px 25px 30px; + background: url('../images/bck_white_50.png'); + margin: 0px auto; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; + margin-bottom: 20px; +} + +.login-data label { + display: block; + width: 70px; + float: left; + line-height: 22px; + text-align: right; + margin-right: 10px; +} + +.login-data input.text { + width: 140px; +} + +.login-data .row { + padding-bottom: 10px; +} + +.login-data .button { + margin-left: 80px; +} + +.login-footer { + border-top: 1px solid #FFFFFF; + background: url('../images/bck_black_70.png'); + height: 22px; + padding: 10px 15px 10px 15px; + -moz-border-radius: 0px 0px 3px 3px; + -webkit-border-radius: 0px 0px 3px 3px; + border-radius: 0px 0px 3px 3px; +} + +.login-footer .remember { + color: #FFFFFF; + line-height: 22px; + display: block; + float: left; + font-weight: bold; +} + +.login-footer .remember label { + margin-left: 3px; +} + +.login-links { + color: #bbbbbb; + font-size: 11px; +} + +.login-links strong { + font-weight: normal; + color: #FFFFFF; +} + +.login-links a { + color: #FFFFFF; + text-decoration: none; +} + +.login-links a:hover { + color: #FFFFFF; + text-decoration: underline; +} + +/*********************************/ +/********** PAGE LAYOUT **********/ +/*********************************/ + +.pagesize { + width: 1000px; + margin: 0px auto; + text-align: left; +} + +.pagetop { + width: 100%; + min-width: 1000px; + background: url('../images/header.png') top center repeat-x #333333; + border-bottom: 4px solid var(--accent-color-bright); + position: relative; + z-index: 100; +} + +.head { + padding: 0px 0px 0px 0px; + background: url('../images/gear.png') 500px bottom no-repeat; +} + +.head_top { + position: relative; + min-height: 114px; +} + +.main {} + +.main-wrap { + width: 100%; + padding-top: 30px; +} + +/*.page { padding-bottom: 50px; }*/ + +.logo { + padding-top: 20px; +} + +.logo a { + text-decoration: none; +} + +.logo .picture { + float: left; + margin-right: 10px; +} + +.logo .textlogo { + float: left; +} + +.logo .title { + display: block; + font-family: 'Trebuchet MS', arial; + font-size: 28px; + color: #FFFFFF; + font-weight: bold; + margin-top: 5px; + letter-spacing: -0.02em; +} + +.logo .text { + display: block; + font-weight: bold; + color: #BBBBBB; + position: relative; + top: -2px; +} + +.breadcrumb { + width: 100%; +} + +.breadcrumb .bread-links { + line-height: 26px; + font-size: 13px; +} + +.breadcrumb li { + float: left; + margin-right: 5px; +} + +.breadcrumb li.first { + padding-left: 16px; + background: url('../images/ball_yellow_13.png') 0px 7px no-repeat; +} + +.breadcrumb li span { + padding-left: 5px; +} + +/*** main menu ***/ + +.menu {} + +.menu ul { + font-size: 13px; +} + +.menu li { + float: left; + margin-right: 1px; + padding-bottom: 5px; + position: relative; +} + +/* +.menu li a { display: block; line-height: 16px; padding: 7px 15px 7px 15px; background: url('../images/button_glas1.png') center center repeat-x #555555; color: #FFFFFF; text-decoration: none; font-weight: bold; text-shadow: 1px 1px 1px #333333; -moz-border-radius: 3px 3px 3px 3px; -webkit-border-radius: 3px 3px 3px 3px; border-radius: 3px 3px 3px 3px; } +*/ +.menu li a { + display: block; + line-height: 16px; + padding: 7px 15px 7px 15px; + background-color: var(--accent-color-bright); + color: #FFFFFF; + text-decoration: none; + font-weight: bold; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +/* +.menu li a:hover { background: url('../images/button_glas1_ovr.png') center center repeat-x #666666; } +*/ +.menu li a:hover { + background-color: var(--accent-color-bright); +} + +/* +.menu li.active a { background: #ffffff; color: #333333; text-shadow: 1px 1px 1px #BBBBBB;} +*/ +.menu li.active a { + background: var(--accent-color-bright); + color: #FFFFFF; +} + +.menu ul ul { + display: none; + font-size: 12px; + width: 150px; + position: absolute; + top: 33px; + left: 0px; + /*background: url('../images/bck_white_95.png');*/ + background-color: white; + padding: 8px 0px 12px 0px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; + -moz-box-shadow: 1px 2px 2px #888888; + -webkit-box-shadow: 1px 2px 2px #888888; + box-shadow: 1px 2px 2px #888888; + /* IE7 hack */ + *border-right: 1px solid #BBBBBB; + *border-bottom: 1px solid #BBBBBB; + /* IE8 hack */ + border-right: 1px solid #BBBBBB; + border-bottom: 1px solid #BBBBBB; +} + +.menu li:hover ul {} + +.menu li:hover ul ul { + display: none; + position: absolute; + top: -8px; + left: 151px; +} + +.menu li li:hover ul {} + +.menu li li { + padding: 0px 0px 0px 0px; + margin: 0px; + float: none; +} + +.menu li li a { + padding: 4px 12px 4px 22px !important; + background: url('../images/arrow_sm_grey.gif') 12px 10px no-repeat !important; + font-weight: normal; + color: #959595 !important; + text-shadow: none !important; +} + +.menu li li a:hover { + color: #333333 !important; + background: url('../images/arrow_sm_black.gif') 12px 10px no-repeat !important; +} + +.menu li li:hover a { + color: #333333 !important; + background: url('../images/arrow_sm_black.gif') 12px 10px no-repeat !important; +} + +.menu li li:hover li a { + color: #959595 !important; + background: url('../images/arrow_sm_grey.gif') 12px 10px no-repeat !important; +} + +.menu li li:hover li a:hover { + color: #333333 !important; + background: url('../images/arrow_sm_black.gif') 12px 10px no-repeat !important; +} + + +.menu li li.active a { + color: #333333 !important; + background: url('../images/arrow_sm_black.gif') 12px 10px no-repeat !important; + font-weight: bold; +} + +.menu li li.active li a { + color: #959595 !important; + background: url('../images/arrow_sm_grey.gif') 12px 10px no-repeat !important; + font-weight: normal; +} + +.menu li li.active li a:hover { + color: #333333 !important; +} + +.menu li li li.active a { + color: #333333 !important; + background: url('../images/arrow_sm_black.gif') 12px 10px no-repeat !important; + font-weight: bold; +} + +/*** header ***/ + +.header { + background: url('../images/line.gif') bottom left repeat-x; + margin-bottom: 30px; +} + +.header .links { + float: right; + line-height: 24px; + color: #333333; + padding: 6px 0px 6px 0px; +} + +.header .links li { + float: left; + margin-left: 10px; +} + +.header .links .icon { + display: block; + width: 24px; + height: 24px; + float: left; + margin-right: 5px; +} + +.header .links a { + color: #333333; + text-decoration: none; +} + +.header .links a:hover { + color: #00A5C4; + text-decoration: none; +} + +.topbuts { + position: absolute; + top: 0px; + right: 0px; +} + +.topbuts ul { + float: right; +} + +.topbuts li { + float: left; + margin-left: 2px; + font-size: 13px; + font-weight: bold; +} + +.topbuts li a { + background: var(--accent-color); + text-decoration: none; + display: block; + color: #FFFFFF; + line-height: 16px; + padding: 1px 12px 2px 12px; +} + +.topbuts li a:hover { + background: var(--accent-color-bright); + text-decoration: none; + color: #FFFFFF; +} + +.topbuts li a.red { + background: #D80017; +} + +.topbuts li a.red:hover { + background: #FF001C; +} + +.user { + clear: both; + float: right; + padding-top: 27px; +} + +.user img.avatar { + display: block; + float: right; + margin-left: 17px; + padding: 3px; + background: #FFFFFF; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.user-detail { + display: block; + float: right; + text-align: right; +} + +.user-detail .name { + display: block; + line-height: normal; + text-align: left; + float: right; + font-size: 18px; + color: #ffffff; + padding: 2px 0px 7px 0px; +} + +.user-detail .text { + color: #bbbbbb; + clear: both; + line-height: 18px; + color: #FFFFFF; + display: block; +} + +.user-detail a { + color: #FFC000; + text-decoration: none; +} + +.user-detail a:hover { + color: #FFC000; + text-decoration: underline; +} + + +/*** main page contents ***/ + +h1 { + color: #333333; + font-size: 30px; + font-weight: normal; + padding-top: 0px; + margin-bottom: 10px; +} + +h1 a.label { + color: #FFFFFF; + text-decoration: none; + /* background: url("../images/button_glas1.png") repeat-x center center #c0c0c0;*/ + display: inline-block; + font-size: 11px; + padding: 2px 10px 2px 10px; + position: relative; + top: -12px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +h1 a.label:hover { + color: #FFFFFF; + text-decoration: none; + background-color: #00A5C4; +} + +h2 { + color: #333333; + font-size: 18px; + font-weight: normal; + padding-top: 0px; + margin-bottom: 10px; +} + +h3 { + color: #333333; + font-size: 18px; + font-weight: bold; + padding-top: 0px; + margin-bottom: 10px; +} + +h4 { + color: #333333; + font-size: 14px; + font-weight: bold; + padding-top: 0px; + margin-bottom: 5px; +} + +h5 { + color: #959595; + font-size: 14px; + font-weight: bold; + padding-top: 0px; + margin-bottom: 5px; +} + +h6 { + color: #333333; + font-size: 14px; + font-weight: bold; + padding-top: 0px; + margin-bottom: 5px; + padding-left: 1px; +} + +.page p { + margin: 0px; + padding-bottom: 20px; + line-height: 16px; +} + +.page p.description { + margin: 0px; + padding-bottom: 5px; + padding-top: 0px; + font-size: 11px; + line-height: 14px; +} + +.thumb { + display: block; + border: 1px solid #BBBBBB; + padding: 3px; + background: #FFFFFF; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +a .thumb { + border: 1px solid #BBBBBB; +} + +a:hover .thumb { + border: 1px solid #00A5C4; +} + +.size48 { + width: 48px; + height: 48px; +} + +.size64 { + width: 64px; + height: 64px; +} + +.code { + background: url('../images/bck_white_90.png'); + color: #00A5C4; + font-family: "Courier New", Courier, monospace, sans-serif; + font-size: 12px; + padding: 2px 5px 3px 5px; + margin-bottom: 5px; +} + +.code span { + color: #ff001c; +} + +ul.standard { + list-style-type: square; + padding: 10px 0 20px 16px; +} + +ul.standard ul { + list-style-type: square; + padding: 5px 0 5px 16px; +} + +ol.standard { + list-style-type: decimal; + padding: 10px 0 20px 22px; +} + +ol.standard ol { + list-style-type: lower-alpha; + padding: 5px 0 5px 22px; +} + + +/* tree list */ + +ul.tree { + list-style-type: none; + padding: 0px 0 20px 0px; +} + +ul.tree ul { + padding-left: 5px; +} + +ul.tree li { + line-height: 20px; + padding: 0px 0px 0px 5px; +} + +ul.tree li span.item { + padding-left: 20px; + background: url('../images/ball_blue_16.png') 0px 2px no-repeat; + font-weight: bold; + display: block; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; + cursor: pointer; +} + +ul.tree li li span.item { + background: url('../images/ball_yellow_13.png') 2px 4px no-repeat; + font-weight: normal; +} + +ul.tree li li li span.item { + background: url('../images/ball_green_13.png') 2px 4px no-repeat; +} + +ul.tree li li li li span.item { + background: url('../images/ball_purple_13.png') 2px 4px no-repeat; +} + +ul.tree li li li li li span.item { + background: url('../images/ball_black_13.png') 2px 4px no-repeat; +} + +ul.tree li li li li li li span.item { + background: url('../images/ball_red_13.png') 2px 4px no-repeat; +} + +ul.tree li li li li li li li span.item { + background: url('../images/ball_grey_13.png') 2px 4px no-repeat; +} + +ul.tree li li { + padding-left: 10px !important; +} + +ul.tree li.tree-item-main { + padding-left: 0px; +} + +ul.tree li.tree-item { + padding-left: 0px; +} + +ul.tree ul { + background: url('../images/tree_line.gif') 7px top no-repeat; +} + +ul.tree ul ul { + background: none; +} + +ul.tree li.last ul { + background: none; +} + +li.tree-item { + background: url('../images/tree_simple.png') 0px 0px no-repeat; +} + +li.tree-item.last { + background: url('../images/tree_simple_last.png') 0px 0px no-repeat; +} + +li.tree-item.parent { + background: url('../images/tree_point.png') 0px 0px no-repeat; +} + +li.tree-item.parent.last { + background: url('../images/tree_point_last.png') 0px 0px no-repeat; +} + + +ul.space, +ol.space { + padding-bottom: 10px; +} + +.space li { + padding-bottom: 10px; +} + +dl.standard { + padding: 10px 0px 15px 0px; +} + +dl.standard dt { + color: #333333; + font-weight: bold; +} + +dl.standard dd { + padding-bottom: 10px; +} + +.mark { + background: #FFFFFF; + color: #333333; + display: block; + padding: 10px 10px 0px 10px; + margin-bottom: 15px; + border: 1px solid #DDDDDD; + border-bottom: 1px solid #CCCCCC; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.mark p { + padding-bottom: 10px; +} + +.mark_blue { + background: #E7EEF4; + color: #333333; + display: block; + padding: 10px 10px 0px 10px; + margin-bottom: 15px; + /*border: 1px solid #D7DEE4;*/ + border-bottom: 1px solid #C6CDD3; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.mark_blue p { + padding-bottom: 10px; +} + + +/*************** +**** COLUMNS *** +***************/ + +/* main columns */ + +.columns { + width: 100%; +} + +.lastcol { + margin-right: 0px !important; +} + +.col1-2 { + width: 484px; + float: left; + margin-right: 32px; +} + +.col1-3 { + width: 312px; + float: left; + margin-right: 32px; +} + +.col2-3 { + width: 656px; + float: left; + margin-right: 32px; +} + +.col1-4 { + width: 226px; + float: left; + margin-right: 32px; +} + +.col2-4 { + width: 484px; + float: left; + margin-right: 32px; +} + +.col3-4 { + width: 742px; + float: left; + margin-right: 32px; +} + +/* cols inside of main columns */ + +.col2-3 .col1-2 { + width: 312px; + float: left; + margin-right: 32px; +} + +.col2-3 .col1-3 { + width: 198px; + float: left; + margin-right: 31px; +} + +.col2-3 .col2-3 { + width: 427px; + float: left; + margin-right: 31px; +} + +.col3-4 .col1-3 { + width: 226px; + float: left; + margin-right: 32px; +} + +.col3-4 .col2-3 { + width: 484px; + float: left; + margin-right: 32px; +} + +.col3-4 .col1-4 { + width: 163px; + float: left; + margin-right: 30px; +} + +.col3-4 .col2-4 { + width: 356px; + float: left; + margin-right: 30px; +} + +.col3-4 .col3-4 { + width: 549px; + float: left; + margin-right: 30px; +} + +/* cols inside of fullboxes */ + +.content-box .col1-2 { + width: 468px; + float: left; + margin-right: 18px; +} + +.content-box .col1-3 { + width: 306px; + float: left; + margin-right: 18px; +} + +.content-box .col2-3 { + width: 630px; + float: left; + margin-right: 18px; +} + +.content-box .col1-4 { + width: 225px; + float: left; + margin-right: 18px; +} + +.content-box .col2-4 { + width: 468px; + float: left; + margin-right: 18px; +} + +.content-box .col3-4 { + width: 711px; + float: left; + margin-right: 18px; +} + +/* cols for boxes with sidebars */ + +.content-box .col1-2 .col1-2 { + width: 225px; + float: left; + margin-right: 18px; +} + +.content-box .col2-3 .col1-2 { + width: 306px; + float: left; + margin-right: 18px; +} + +.content-box .col3-4 .col1-2 { + width: 346px; + float: left; + margin-right: 19px; +} + +.content-box .col3-4 .col1-3 { + width: 225px; + float: left; + margin-right: 18px; +} + +.content-box .col3-4 .col2-3 { + width: 468px; + float: left; + margin-right: 18px; +} + +.sidebar1-2 .sidebar .col1-2 { + width: 214px; + float: left; + margin-right: 20px; +} + +/* cols for boxes(without sidebars only) inside of main columns */ + +.col1-2 .content-box .col1-2 { + width: 210px; + float: left; + margin-right: 18px; +} + +.col2-3 .content-box .col1-2 { + width: 296px; + float: left; + margin-right: 18px; +} + +.col2-3 .content-box .col1-3 { + width: 192px; + float: left; + margin-right: 17px; +} + +.col2-3 .content-box .col2-3 { + width: 401px; + float: left; + margin-right: 17px; +} + +.col2-4 .content-box .col1-2 { + width: 210px; + float: left; + margin-right: 18px; +} + +.col3-4 .content-box .col1-2 { + width: 339px; + float: left; + margin-right: 18px; +} + +.col3-4 .content-box .col1-3 { + width: 220px; + float: left; + margin-right: 18px; +} + +.col3-4 .content-box .col2-3 { + width: 458px; + float: left; + margin-right: 18px; +} + + +/******************************* +**** DESIGN of content boxes *** +*******************************/ + +.content-box { + /*border-width: 1px; border-style: solid; border-color: #DDDDDD #DDDDDD #C4C4C4 #DDDDDD;*/ + background: #FFFFFF; + margin-bottom: 30px; + -moz-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; +} + +.content-box .box-header { + min-height: 36px; + /*background: url('../images/bck_header.png') top center repeat-x;*/ + position: relative; + -moz-border-radius: 4px 4px 4px 4px; + -webkit-border-radius: 4px 4px 4px 4px; + border-radius: 4px 4px 4px 4px; +} + +.content-box .box-body { + border: 1px solid #FFFFFF; + background: #F5F5F5; + -moz-border-radius: 4px 4px 4px 4px; + -webkit-border-radius: 4px 4px 4px 4px; + border-radius: 4px 4px 4px 4px; +} + +.content-box .box-wrap { + padding: 20px 20px 10px 20px; +} + +.box-header h2 { + color: #333333; + line-height: 24px; + margin-bottom: 0px; + padding: 7px 20px 9px 20px; + /*background: url('../images/rule.gif') bottom left repeat-x;*/ + border-bottom: 0.5px solid #DDDDDD; +} + +.box-header .tabs { + position: absolute; + top: 7px; + right: 10px; +} + +.box-header .tabs li { + float: left; + margin-left: 3px; + font-size: 11px; + line-height: 20px; + font-weight: bold; +} + +.box-header .tabs li a { + border: 0px solid #FFFFFF; + display: block; + color: #333333; + text-decoration: none; + padding: 2px 15px 2px 15px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.box-header .tabs li a:hover { + color: #FFFFFF; + background-color: var(--accent-color-bright); + text-decoration: none; +} + +.box-header .tabs li.active a { + color: #FFFFFF; + background-color: var(--accent-color-bright); + border: 0px solid #333333; +} + +.box-header .tabs li a.selected { + color: #FFFFFF; + background-color: var(--accent-color-bright); + border: 0px solid #333333; +} + +/* sliding boxes and tabs */ + +.box-slide-head span.slide-but, +.box-slide-head td.slide-but span { + display: block; + text-indent: -9999px; + width: 26px; + height: 24px; + background: url('../images/but_slide.png') center center no-repeat !important; + cursor: pointer; +} + +.box-slide-head td.slide-but { + background: none; +} + +td.box-slide-body { + background: #F0f0f0 !important; +} + +.box-header.box-slide-head .slide-but { + position: absolute; + top: 7px; + right: 5px; +} + +.box-header.box-slide-head .tabs { + right: 36px; +} + +/* iconbar */ + +.content-box .iconbar { + background: #e9e9e9 url('../images/bck_iconbar.png') top center repeat-x; + height: 143px; + overflow: hidden; +} + +.content-box .iconbar .box-wrap { + background: url('../images/bck_iconbar_bottom.png') center bottom repeat-x; + padding-top: 15px; + padding-bottom: 25px; + padding-left: 42px; + padding-right: 42px; + position: relative; + overflow: hidden; + height: 103px; +} + +.iconbar .jcarousel-list { + height: 100px; + overflow: hidden; +} + +.iconbar .jcarousel-prev { + background: #f9f9f9; + display: block; + width: 20px; + height: 40px; + text-indent: -9999px; + border: 1px solid #FFFFFF; + border-left-width: 0px; + position: absolute; + top: 29px; + left: -42px; + -moz-border-radius: 0px 3px 3px 0px; + -webkit-border-radius: 0px 3px 3px 0px; + border-radius: 0px 3px 3px 0px; + -moz-box-shadow: 2px 1px 4px #dddddd; + -webkit-box-shadow: 2px 1px 4px #dddddd; + box-shadow: 2px 1px 4px #dddddd; + *border-right: 1px solid #DDDDDD; + *border-bottom: 1px solid #DDDDDD; +} + +.iconbar .jcarousel-prev-disabled { + background: url('../images/arrowleft_iconbar_off.png') center center no-repeat #f9f9f9 !important; + cursor: auto !important; +} + +.iconbar .jcarousel-prev { + background: url('../images/arrowleft_iconbar_act.png') center center no-repeat #f9f9f9; + cursor: pointer; +} + +.iconbar .jcarousel-prev:hover { + background: url('../images/arrowleft_iconbar_ovr.png') center center no-repeat #ffffff; +} + +.iconbar .jcarousel-next { + background: #f9f9f9; + display: block; + width: 20px; + height: 40px; + text-indent: -9999px; + border: 1px solid #FFFFFF; + border-right-width: 0px; + position: absolute; + top: 29px; + right: -42px; + -moz-border-radius: 3px 0px 0px 3px; + -webkit-border-radius: 3px 0px 0px 3px; + border-radius: 3px 0px 0px 3px; + -moz-box-shadow: -2px 1px 4px #dddddd; + -webkit-box-shadow: -2px 1px 4px #dddddd; + box-shadow: -2px 1px 4px #dddddd; + *border-left: 1px solid #DDDDDD; + *border-bottom: 1px solid #DDDDDD; +} + +.iconbar .jcarousel-next-disabled { + background: url('../images/arrowright_iconbar_off.png') center center no-repeat #f9f9f9 !important; + cursor: auto !important; +} + +.iconbar .jcarousel-next { + background: url('../images/arrowright_iconbar_act.png') center center no-repeat #f9f9f9; + cursor: pointer; +} + +.iconbar .jcarousel-next:hover { + background: url('../images/arrowright_iconbar_ovr.png') center center no-repeat #ffffff; +} + +.main-icons { + margin: 0px; + padding: 0px; +} + +.main-icons ul { + width: 100%; +} + +.main-icons li { + width: 120px; + float: left; + text-align: center; + margin: 0px 5px 0px 5px; + padding: 1px 0px 5px 0px; +} + +.main-icons li.active, +.main-icons li:hover { + background: url('../images/bck_iconbar_ovr.png') center -15px no-repeat; + width: 118px; + padding: 0px 0px 5px 0px; + border: 1px solid #f3f3f3; + border-bottom-width: 0px; + -moz-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; +} + +.main-icons a { + color: #333333; + text-decoration: none; + display: block; + padding-top: 14px; + text-shadow: 0px 1px 2px rgba(0, 0, 0, .25); +} + +.main-icons a:hover { + color: #00A5C4; + text-decoration: none; + padding-top: 13px; + text-shadow: 0px 1px 10px rgba(255, 255, 255, 1.0); + border: 1px solid #FFFFFF; + border-bottom-width: 0px; + -moz-border-radius: 4px 4px 0px 0px; + -webkit-border-radius: 4px 4px 0px 0px; + border-radius: 4px 4px 0px 0px; +} + +.main-icons li.active a { + color: #47AB00; + text-decoration: none; + padding-top: 13px; + text-shadow: 0px 1px 1px rgba(71, 171, 0, .4); + border: 1px solid #FFFFFF; + border-bottom-width: 0px; + -moz-border-radius: 4px 4px 0px 0px; + -webkit-border-radius: 4px 4px 0px 0px; + border-radius: 4px 4px 0px 0px; +} + +.main-icons .icon { + display: block; + width: 64px; + height: 64px; + margin: 0px auto; +} + +.main-icons .text { + display: block; + padding: 5px 0px 0px 0px; +} + +/* elements inside of content boxes */ + +.box-body p { + margin: 0px; + margin: 0px; + padding-bottom: 20px; +} + +/*** sidebars ***/ + +.content-box .sidebar1-2 { + width: 100%; + background: url('../images/bck_sidebar.png') 486px 0px repeat-y; + margin-bottom: 10px; +} + +.content-box .sidebar1-2 .sidebar { + width: 448px; + float: right; + padding: 10px 10px 15px 10px; +} + +.content-box .sidebar1-3 { + width: 100%; + background: url('../images/bck_sidebar.png') 648px 0px repeat-y; + margin-bottom: 10px; +} + +.content-box .sidebar1-3 .sidebar { + width: 286px; + float: right; + padding: 10px 10px 15px 10px; +} + +.content-box .sidebar1-4 { + width: 100%; + background: url('../images/bck_sidebar.png') 729px 0px repeat-y; + margin-bottom: 10px; +} + +.content-box .sidebar1-4 .sidebar { + width: 205px; + float: right; + padding: 10px 10px 15px 10px; +} + +.sidemenu { + width: 100%; +} + +.sidemenu ul.list { + padding-bottom: 15px; + line-height: 16px; +} + +.sidemenu ul.list li { + margin-bottom: 3px; + background: url('../images/ui-bg_glass_100_f6f6f6_1x400.png') left center repeat-x; + border: 1px solid #DDDDDD; + border-bottom: 1px solid #CCCCCC; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.sidemenu ul.list li:hover { + background: #FFFFFF; +} + +.sidemenu ul.list a { + color: #00A5C4; + text-decoration: none; + background: url('../images/arrow_sm_blue.gif') 7px 10px no-repeat; + display: block; + padding: 5px 5px 5px 17px; +} + +.sidemenu ul.list a:hover { + color: #00A5C4; + text-decoration: none; + background: url('../images/arrow_sm_blue.gif') 7px 10px no-repeat; +} + +.sidemenu ul.list li.active { + background: #FFFFFF; +} + +.sidemenu ul.list li.active a { + color: #333333; + text-decoration: none; + background: url('../images/arrow_sm_black.gif') 7px 10px no-repeat; + font-weight: bold; +} + +.sidebar p { + padding-bottom: 10px; +} + + +/************** +**** tables *** +**************/ + +table { + width: 100%; + margin: 0px; + margin-bottom: 20px; +} + +table.basic { + border-spacing: 0px; + border-collapse: separate; + border-top: 3px solid #cccccc; + border-bottom: 1px solid #FFFFFF; + line-height: 16px; +} + +table.basic caption { + background: none #bbbbbb; + color: #FFFFFF; + font-size: 14px; + padding: 5px 11px 5px 11px; + text-align: center; +} + +table.basic tr:hover { + background: none !important; +} + +table.basic thead { + color: #333333; +} + +table.basic th, +table.basic .title { + border-top: 1px solid #FFFFFF; + border-bottom: 1px solid #cccccc; + font-weight: bold; + padding: 3px 6px 3px 0px; + white-space: nowrap; + text-align: left; +} + +table.basic tbody th {} + +table.basic td { + background: none !important; + border-top: 1px solid #FFFFFF; + border-bottom: 1px solid #cccccc; + padding: 3px 8px 3px 0px !important; + line-height: 16px !important; +} + +table.basic thead { + border-top: 1px solid #FFFFFF; + border-bottom: 1px solid #cccccc; + padding: 3px 6px 3px 0px; + white-space: nowrap; +} + +table.basic td p { + padding-bottom: 3px; + padding-top: 3px; +} + + +table.style1 { + border: 0px solid #DDDDDD; +} + +table.style1 caption { + background: none #959595; + color: #FFFFFF; + font-size: 14px; + padding: 5px 11px 5px 11px; + text-align: left; +} + +table.style1 tr:hover { + background-color: #DDDDDD; + /*background: url('../images/bck_black_10.png');*/ +} + +table.style1 th { + background: #DDDDDD; + border-bottom: 1px solid #FFFFFF; + line-height: 22px; + padding: 4px 6px 4px 6px; + color: #333333; + white-space: nowrap; + text-align: left; +} + +table.style1 thead th { + padding-top: 2px; +} + +table.style1 thead td { + background: #DDDDDD; + border-bottom: 1px solid #FFFFFF; + text-align: left; + line-height: 22px; + padding: 2px 6px 4px 6px; + white-space: nowrap; +} + +table.style1 tbody th, +table.style1 tbody .title { + /*background: url('../images/bck_black_5.png');*/ + font-weight: bold; + white-space: nowrap; + color: #666666; +} + +table.style1 td { + /*background: url('../images/bck_white_75.png');*/ + border-bottom: 1px solid #DDDDDD; + line-height: 22px; + padding: 4px 6px 4px 6px; +} + +table.style1 .icon16 { + margin-top: 3px; + margin-bottom: 3px; +} + +table.style1 td.vcenter { + vertical-align: middle; +} + +table.style1 td p { + padding-bottom: 3px; + padding-top: 3px; +} + +.chart-wrap table { + border: 5px solid #DDDDDD; +} + +.chart-wrap caption { + background: none #959595; + color: #FFFFFF; + font-size: 14px; + padding: 5px 11px 5px 11px; + text-align: left; +} + +.chart-wrap tr:hover { + background: url('../images/bck_black_10.png'); +} + +.chart-wrap th { + background: #DDDDDD; + border-bottom: 1px solid #FFFFFF; + text-align: left; + line-height: 22px; + padding: 4px 6px 4px 6px; + color: #333333; + white-space: nowrap; +} + +.chart-wrap thead th { + padding-top: 2px; +} + +.chart-wrap thead td { + background: #DDDDDD; + border-bottom: 1px solid #FFFFFF; + text-align: left; + line-height: 22px; + padding: 2px 6px 4px 6px; + white-space: nowrap; +} + +.chart-wrap tbody th { + background: url('../images/bck_black_5.png'); + font-weight: bold; + white-space: nowrap; + color: #666666; +} + +.chart-wrap td { + background: url('../images/bck_white_75.png'); + border-bottom: 1px solid #DDDDDD; + line-height: 22px; + padding: 4px 6px 4px 6px; +} + +table .vcenter { + vertical-align: middle; +} + +table .full { + width: 100%; +} + +table .value { + color: #47AB00; +} + +table .nowrap { + white-space: nowrap; +} + +/*** forms ***/ + +form { + width: 100%; +} + +label { + line-height: 22px; + cursor: pointer; +} + +label:hover { + color: #333333; +} + +input.checkbox { + display: inline; + /*position: relative;*/ + left: 1px; + /*top: 1px;*/ + cursor: pointer; +} + +input.radio { + display: inline; + position: relative; + top: 2px; + left: -1px; +} + +input.text { + display: inline; + border: 1px solid #B8B8B8; + font-size: 12px; + color: #333333; + height: 16px; + padding: 2px 4px 2px 4px; +} + +input.submit { + display: inline; + border-width: 0px; + font-size: 12px; + color: #FFFFFF; + font-weight: bold; + cursor: pointer; + /* background: url('../images/button_glas1.png') center center repeat-x #333333;*/ + padding: 3px 10px 3px 10px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +input.form-file { + display: inline; + height: auto; + font-size: 12px !important; +} + +select { + display: inline; + border: 1px solid #B8B8B8; + font-family: 'Trebuchet MS', arial; + color: #333333; + height: 22px; + padding: 2px; +} + +textarea { + display: inline; + border: 1px solid #B8B8B8; + font-family: 'Trebuchet MS', arial; + color: #333333; + padding: 4px; +} + +.form-label { + display: block; + width: 130px; +} + +.form-field { + padding-bottom: 5px; +} + +span.required { + color: #FF0000; + font-weight: bold; +} + +label.error { + color: #FF0000; +} + +/*** page navigation ***/ + +.tab-footer { + width: auto; + padding-bottom: 10px; + margin-top: -10px; +} + +.pager { + font-size: 11px; + line-height: 20px; +} + +.pager a { + display: block; + float: left; +} + +.pager .nav { + display: block; + float: left; +} + +.pager .nav a { + width: 20px; + height: 20px; + border: 1px solid #dddddd; + border-bottom: 1px solid #cccccc; +} + +.pager .nav a span { + display: block; + font-size: 0%; + visibility: hidden; + text-indent: -9999px; +} + +.pager a.first { + background: url('../images/arrow_leftend_off.png') center center no-repeat; + border-right-width: 0px; + -moz-border-radius: 3px 0px 0px 3px; + -webkit-border-radius: 3px 0px 0px 3px; + border-radius: 3px 0px 0px 3px; +} + +.pager a.first:hover { + background: url('../images/arrow_leftend_ovr.png') center center no-repeat; +} + +.pager a.previous { + background: url('../images/arrow_left_off.png') center center no-repeat; + border-left-width: 0px; + -moz-border-radius: 0px 3px 3px 0px; + -webkit-border-radius: 0px 3px 3px 0px; + border-radius: 0px 3px 3px 0px; +} + +.pager a.previous:hover { + background: url('../images/arrow_left_ovr.png') center center no-repeat; +} + +.pager a.last { + background: url('../images/arrow_rightend_off.png') center center no-repeat; + border-left-width: 0px; + -moz-border-radius: 0px 3px 3px 0px; + -webkit-border-radius: 0px 3px 3px 0px; + border-radius: 0px 3px 3px 0px; +} + +.pager a.last:hover { + background: url('../images/arrow_rightend_ovr.png') center center no-repeat; +} + +.pager a.next { + background: url('../images/arrow_right_off.png') center center no-repeat; + border-right-width: 0px; + -moz-border-radius: 3px 0px 0px 3px; + -webkit-border-radius: 3px 0px 0px 3px; + border-radius: 3px 0px 0px 3px; +} + +.pager a.next:hover { + background: url('../images/arrow_right_ovr.png') center center no-repeat; +} + +.pager .pages { + display: block; + float: left; + margin: 0px 4px 0px 4px; + font-weight: bold; +} + +.pager .pages a { + min-width: 20px; + margin: 0px 1px 0px 1px; + /*background: url("../images/button_glas2.png") repeat-x center center #EEEEEE;*/ + text-align: center; + border: 1px solid #dddddd; + border-bottom: 1px solid #cccccc; + color: #333333; + text-decoration: none; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.pager .pages a span { + padding: 0px 2px 0px 2px; +} + +.pager .pages a:hover { + background: url('../images/bck_white_50.png'); + color: #00A5C4; + text-decoration: none; +} + +.pager .pages a.active { + background: url('../images/page_active.gif'); + color: #FFFFFF; + text-decoration: none; + font-weight: bold; + border-width: 0px; + line-height: 22px; + min-width: 22px; +} + + +/*** notifications ***/ + +.notification { + border: 1px solid #666666; + border-radius: 3px; + display: block; + margin-bottom: 15px; + overflow: hidden; + padding: 9px 0px 4px 0px; + position: relative; + z-index: 1; + zoom: 1; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +.notification a.close { + display: block; + width: 11px; + height: 11px; + background: url('../images/ico_close_off.png') top left no-repeat; + font-size: 0%; + text-indent: -9999px; + position: absolute; + top: 3px; + right: 3px; +} + +.notification a.close:hover { + background: url('../images/ico_close_ovr.png') top left no-repeat; +} + +.notification p { + color: #333333; + line-height: 16px; + padding: 0px 25px 5px 10px !important; +} + +.note-error { + background-color: #EC9B9B; + border-color: #EC9B9B; +} + +.note-success { + background-color: #DFFAD3; + border-color: #72CB67; +} + +.note-info { + background-color: #DDE9F7; + border-color: #50B0EC; +} + +.note-attention { + background-color: #FFFAC6; + border-color: #D3C200; +} + + +/*** footer ***/ +.footer { + color: #BBBBBB; + width: 100%; + min-width: 1000px; + font-size: 12px; + padding: 30px 0px 30px 0px; + background: #333333; + border-top: 4px solid #ffffff; +} + +.footer .pagesize { + text-align: center; +} + +.footer a { + font-weight: bold; +} + +.footer .copy a { + color: #FFFFFF; +} + +.footer .copy a:hover { + color: #FFFFFF; +} + +.footer strong { + color: #FFFFFF; +} + +/* ********************************************************************* + * Modal window + * *********************************************************************/ + +.pagetop .modal-window, +.main .modal-window, +.footer .modal-window { + display: none; +} + +.modal-window { + width: auto; +} + +.modal-window p { + padding-bottom: 20px; + line-height: 16px; +} + +.modal-400 { + width: 400px; +} + +.modal-600 { + width: 600px; +} + +.modal-800 { + width: 800px; +} + +#fancybox-img { + width: auto !important; +} + +/* ********************************************************************* + * Quick edit + * *********************************************************************/ + +.edit-field textarea { + width: 95% !important; +} + +.edit-field input { + width: auto !important; + display: block; + border: 1px solid #B8B8B8; + font-size: 12px; + font-family: 'Trebuchet MS', arial; + color: #333333; + height: 16px; + padding: 2px 4px 2px 4px; +} + +.long input { + width: 95% !important; +} + + +/* ********************************************************************* + * Gallery + * *********************************************************************/ + +.gallery { + background: #FFFFFF; + color: #333333; + display: block; + padding: 10px 10px 10px 10px; + margin-bottom: 15px; + border: 1px solid #DDDDDD; + border-bottom: 1px solid #CCCCCC; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.gallery li { + display: inline-block; + float: left; + vertical-align: top; +} + +.gal-large li { + width: 88px; + margin: 5px 5px 0px 5px; + padding-bottom: 10px; +} + +.gal-large .thumb { + display: block; + width: 80px; + height: 80px; + border: 1px solid #BBBBBB; + padding: 3px; + background: #FFFFFF; + margin-bottom: 4px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +.gal-large .title { + color: #333333; + display: block; + font-size: 11px; + line-height: 18px; + padding: 0px 6px 0px 6px; + background: url('../images/ui-bg_glass_100_f6f6f6_1x400.png') left center repeat-x; + border: 1px solid #DDDDDD; + border-bottom: 1px solid #CCCCCC; + text-align: center; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +.gal-large .title .wrap { + display: block; + white-space: nowrap; + overflow: hidden; +} + +.gal-large li:hover .title { + color: #FFFFFF; + height: 20px; + position: relative; + border-width: 0px; + padding: 0px; +} + +.gal-large li:hover .wrap { + padding: 0px 6px 0px 6px; + border: 1px solid #333333; + min-width: 74px; + overflow: visible; + position: absolute; + top: 0px; + left: 0px; + background: #333333; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +.gal-small { + padding: 8px; +} + +.gal-small li { + width: 56px; + margin: 3px 3px 0px 3px; + padding-bottom: 5px; +} + +.gal-small .thumb { + display: block; + width: 48px; + height: 48px; + border: 1px solid #BBBBBB; + padding: 3px; + background: #FFFFFF; + margin-bottom: 0px; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +.gal-footer { + padding-bottom: 20px; +} + + +/* ************************** + * Custom contents + * **************************/ + +/* user boxes */ + +.online-user { + padding-bottom: 15px; +} + +.online-user .mark { + padding-bottom: 10px; + margin-bottom: 5px; +} + +.online-user .avatar { + width: 50px; + float: left; + margin-right: 10px; + border: 2px solid #DDDDDD; +} + +.online-user .avatar img { + display: block; + width: 48px; + height: 48px; + padding: 1px 1px 0px 1px; + background: #FFFFFF; + border-bottom: 0px; +} + +.online-user .desc { + margin-left: 64px; +} + +.online-user ul.links { + float: right; +} + +.online-user ul.links li { + float: left; + margin-left: 5px; +} + +.online-user ul.links .graph { + display: block; + width: 16px; + height: 16px; + background: url('../images/ico_graph_16_off.png') 0 0 no-repeat; + text-indent: -9999px; +} + +.online-user ul.links .graph:hover { + background: url('../images/ico_graph_16_ovr.png') 0 0 no-repeat; +} + +.online-user ul.links .cart { + display: block; + width: 16px; + height: 16px; + background: url('../images/ico_shopping_16_off.png') 0 0 no-repeat; + text-indent: -9999px; +} + +.online-user ul.links .cart:hover { + background: url('../images/ico_shopping_16_ovr.png') 0 0 no-repeat; +} + +.online-user ul.links .hist { + display: block; + width: 16px; + height: 16px; + background: url('../images/ico_history_16_off.png') 0 0 no-repeat; + text-indent: -9999px; +} + +.online-user ul.links .hist:hover { + background: url('../images/ico_history_16_ovr.png') 0 0 no-repeat; +} + +.online-user ul.links .mesg { + display: block; + width: 16px; + height: 16px; + background: url('../images/envelope_off.png') 0 0 no-repeat; + text-indent: -9999px; +} + +.online-user ul.links .mesg:hover { + background: url('../images/envelope_ovr.png') 0 0 no-repeat; +} + +.online-user ul.links .male { + display: block; + width: 16px; + height: 16px; + background: url('../images/user_male.png') 0 0 no-repeat; + text-indent: -9999px; + cursor: help; +} + +.online-user ul.links .female { + display: block; + width: 16px; + height: 16px; + background: url('../images/user_female.png') 0 0 no-repeat; + text-indent: -9999px; + cursor: help; +} + +.online-user p { + padding-bottom: 5px; + color: #959595; +} + +.online-user p.status { + color: #ffffff; + font-size: 10px; + font-weight: bold; + line-height: 14px; + text-align: center; + padding: 0px 0px 2px 0px; + background: #333333; + margin: 0px 1px 1px 1px; +} + +.online-user p.admin { + color: #D3FF77; +} + +.online-user .info { + color: #333333; + padding-bottom: 0px; +} + +/* links with icons */ + +.icon-links { + margin-bottom: 10px; +} + +.icon-links li { + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px dashed #dddddd; +} + +.icon-links li.lastlnk { + padding-bottom: 10px; + margin-bottom: 0px; + border-bottom-width: 0px; +} + +.icon-links .icon { + width: 48px; + height: 48px; + float: left; + background: url('../images/bck_icon48_dark.png') 0 0 no-repeat; + padding: 6px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.icon-links a span { + margin-left: 70px; + display: block; + font-size: 13px; + padding-top: 6px; + font-weight: bold; +} + +.icon-links a:hover span { + text-decoration: underline; +} + +.icon-links p { + margin-left: 70px; + padding-bottom: 0px; + padding-top: 5px; +} + +/* event list */ + +.event-list {} + +.event-list .priority-high { + display: block; + width: 16px; + height: 16px; + background: url('../images/ball_red_16.png') center left no-repeat; + float: left; + text-indent: -9999px; + cursor: help; +} + +.event-list .priority-normal { + display: block; + width: 16px; + height: 16px; + background: url('../images/ball_yellow_16.png') center left no-repeat; + float: left; + text-indent: -9999px; + cursor: help; +} + +.event-list .priority-low { + display: block; + width: 16px; + height: 16px; + background: url('../images/ball_blue_16.png') center left no-repeat; + float: left; + text-indent: -9999px; + cursor: help; +} + +.event-list ul { + padding-bottom: 10px; + padding-right: 2px; + margin-top: -5px +} + +.event-list li { + width: 100%; + float: left; + clear: both; + border: 1px solid #DDDDDD; + background: #E9E9E9; + border-bottom: 1px solid #CCCCCC; + margin-top: 5px; + -moz-border-radius: 3px 3px 3px 3px; + -webkit-border-radius: 3px 3px 3px 3px; + border-radius: 3px 3px 3px 3px; +} + +.event-list .event-list-title { + display: block; + background: url('../images/ui-bg_glass_100_f6f6f6_1x400.png') center center repeat-x #ffffff; + line-height: 16px; + padding: 3px 4px 3px 4px; + -moz-border-radius: 2px 2px 2px 2px; + -webkit-border-radius: 2px 2px 2px 2px; + border-radius: 2px 2px 2px 2px; +} + +.event-list .event-list-title a { + font-weight: bold; +} + +.event-list .event-edit { + display: block; + float: right; + margin-left: 5px; + width: 16px; + height: 16px; + background: url('../images/ico_edit_16.png') center center no-repeat; + text-indent: -9999px; +} + +.event-list .event-date { + color: #333333; + font-size: 11px; + line-height: 14px; + display: block; + float: right; + margin-left: 5px; + margin-top: 2px; +} + +.event-list .event-link { + display: block; + margin: 0px 0px 0px 21px; +} + +.event-list .event-note { + font-size: 11px; + line-height: 14px; + padding: 5px 6px 5px 6px; +} + +/* event calendar */ + +.event-calendar {} + +.event-calendar .datepicker-inline { + padding-bottom: 5px; +} + +.event-calendar .add-event { + padding-bottom: 10px; +} + +.event-calendar .event-date { + float: left; +} + +.event-calendar .event-form { + margin: 0px -20px 0px -20px; + padding-top: 10px; +} + +.event-calendar .event-wrap { + width: 100%; + border-bottom: 1px solid #fff; + background: url('../images/rule.gif') top left repeat-x #E9E9E9; + padding: 22px 0px 10px 0px; +} + +.event-calendar .event-form .form-field { + padding-left: 20px; + padding-right: 20px; +} + +.event-calendar .title { + width: 256px; +} + +.event-calendar .event { + width: 256px; +} + +/* articles and categories */ + +.article-detail-body { + margin: 0px 125px 0px 195px; +} + +.categories span.item { + position: relative; +} + +.categories span.item:hover { + background-color: #ffffff; + color: #333333; +} + +.categories .cat-links { + display: block; + position: absolute; + top: 2px; + right: 2px; +} + +.categories .cat-links a { + display: block; + float: left; + margin-left: 3px; +} + +.categories .cat-edit { + width: 16px; + height: 16px; + text-indent: -9999px; + background: url('../images/ico_edit_12.png') center center no-repeat; +} + +.categories .cat-del { + width: 16px; + height: 16px; + text-indent: -9999px; + background: url('../images/ico_delete_12.png') center center no-repeat; +} + +.add-category .title { + width: 234px; +} + +.add-category .cat-descr { + width: 234px; +} + +.add-category .cat-parent { + width: 100%; +} + +/*----------------------------------------- + * Override default settings. + *---------------------------------------- + */ +/* reset font size */ +html, +body, +div, +span, +applet, +object, +iframe, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +font, +img, +ins, +kbd, +q, +s, +samp, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +select, +input, +button { + font-family: 'PT Sans', 'Trebuchet MS', arial; + font-size: 13px; +} + +/* reset page width */ +body { + min-width: 1024px; +} + +.pagesize { + width: 1024px; +} + +.pagetop { + min-width: 1024px; +} + +.footer { + min-width: 1024px; +} + +table.style1 caption { + font-size: 13px; + font-weight: bold; +} + +html, +body { + background: none repeat scroll 0 0 #F9F9F9; +} + +table thead th.checkbox, +table tbody td.checkbox { + width: 10px; +} + +table.style1 td { + background-color: white; +} + +table.style1 caption { + background-color: #F5F5F5; +} + +label { + cursor: default; +} + +label:hover { + color: inherit; +} + +.topbuts li a { + font-size: 12px; + line-height: 18px; +} + +.login-header { + height: 45px; + font-size: 16px; +} + +.login-header .logo .title { + font-size: 16px; +} + +.login-box { + width: 530px; + border: 0px; + padding: 0px; +} + +.login-inside { + height: 165px; + padding-top: 25px; +} + +.login-data { + background: none; +} + +.login-data label { + width: 90px; +} + +/* +.login-data input.text { font-size: 14px; } +*/ +.login-data .button { + margin-left: 100px; +} + +.pagetop { + padding: 0px 0px 0px 0px; +} + +.head_top { + min-height: 90px; +} + +.logo .textlogo { + padding-left: 10px; +} + +.page { + padding-top: 0px; + padding-bottom: 15px; +} + +.menu li a { + background: none; + line-height: 18px; +} + +.menu ul ul { + padding-bottom: 8px; + width: 210px; +} + +.menu li:hover ul ul { + left: 210px; +} + +h2 { + font-weight: bold; +} + +h4 { + padding-bottom: 5px; + font-size: 13px; +} + +h5, +h6 { + font-size: 12px; +} + +.main-wrap { + padding-top: 10px; +} + +.content-box .box-wrap { + padding: 20px 10px 10px 10px; +} + +.content-box .col1-3 { + width: 300px; +} + +.box-body p { + font-size: 13px; +} + +/* smaller col1-3, so that we can display a border between div.col1-3-compat */ +.content-box .col1-3-compat { + width: 300px; + float: left; + margin-right: 18px; +} + +.content-box .col1-3-compat-left-border { + width: 300px; + float: left; + margin-right: 18px; + padding-left: 17px; + border-left: 1px solid; + border-color: #DDDDDD; +} + +.button, +input.button, +a.button { + font-size: 13px; +} + +.button.grey { + cursor: not-allowed; +} + +input.text { + height: 20px; + font-size: 13px; +} + +.top-space5 { + margin-top: 5px !important; +} + +.left-space5 { + margin-left: 5px !important; +} + +.right-space5 { + margin-right: 5px !important; +} + +.box-header h2 { + padding: 7px 20px 9px 10px; +} + +.box-header .tabs li a { + padding: 2px 10px 2px 10px; +} + +#fancybox-inner { + font-size: 13px; +} + +.textarea { + width: 50%; + font-size: 13px; +} + +small { + font-size: 13px; +} + +select { + height: 24px; + padding: 1px; +} + +.rule { + border-top-style: dashed; + border-top-width: 1px; + /*margin-top: 1em;*/ + margin-bottom: 1em; + border-color: #DBE0E4; +} + +.rule2 { + margin-top: 2em; + margin-bottom: 1em; + padding-top: 0em; + padding-bottom: 0.5em; +} + +.rule3 { + padding-top: 2px; + padding-bottom: 20px; + background: url('../images/rule.gif') 0px 0px repeat-x; +} + +.footer { + padding: 10px 0px 10px 0px; + background: none; + border-top: 0px; +} + +/* + * Custom code + */ + +input.disabled { + cursor: not-allowed; + background-color: #dddddd; + opacity: 1; +} + +.fa, +.fa-lg { + vertical-align: middle; +} + +.fa-plus { + color: #00A5C4; +} + +.fa-warning { + color: #F5B32B; +} + +.vcenter { + vertical-align: middle; +} + +.color-grey { + color: #BBBBBB; +} + +.color-green { + color: green; +} + +.color-red { + color: red; +} + +.color-purple { + color: purple; +} + +.color-blue { + color: blue; +} + +.color-yellow, +.color-warning { + color: #f39c12 !important; +} + +.color-link { + color: #00A5C4; +} + +.color { + color: red; +} + +.size-250 { + width: 250px; +} + +.size-300 { + width: 300px; +} + +.filter_by_first_char { + padding: 5px 0 5px 5px; + display: block; + text-align: center; +} + +.filter_by_first_char a { + padding: 0 2px 0 2px; + display: inline-block; +} + +.filter_by_first_char a.active { + background-color: var(--accent-color-bright); + color: #FFFFFF; + text-decoration: none; + font-weight: bold; + border-width: 0px; +} + +.button.text_label { + background-color: #00A5C4; + color: #FFFFFF; + padding: 2px 5px 2px 5px; + vertical-align: middle; +} + +.button.text_label:hover { + color: #FFFFFF; + text-decoration: none; + background-color: #00A5C4; +} + +/* Simple progress bar + * ------------------------------------------*/ +div.progress-container { + border: 1px solid #ccc; + float: left; + background: white; +} + +div.progress-container>div.progress-bar { + float: none; + border: 0px solid #ccc; +} + +/* Multi Checkbox Widget +-------------------------------------------------------------*/ + +.checklist { + min-height: 6em; + max-height: 15em; + max-width: 40em; + overflow: auto; + border: 1px solid; + position: relative; + padding: 0.25em 0.5em; + margin: 0 1em; +} + +.checklist fieldset { + height: auto; +} + +.checklist legend, +.checklist legend span { + font-weight: bold; + position: static; + padding: 0; + height: auto; + text-align: left; +} + +.checklist .checklist-item { + position: relative; + height: auto; +} + +.checklist .checklist-item label { + display: block; + padding: 0 0 0 1em; + float: none; + height: 100%; + /*background-color: #fff;*/ +} + +.checklist .checklist-item .fld-input { + position: absolute; + left: 0; + top: 0; + padding: 0; + margin: 0; +} + +.checklist .checklist-item input { + margin: 0; + height: 1.8em; + width: 1.55em; +} + +/* jQuery tooltip: http://flowplayer.org/tools/tooltip/index.html */ +.tooltip { + /* + background-color: #000; + color: #fff; + */ + background-color: #333; + color: #fff; + border: 0px solid #fff; + padding: 10px 15px; + display: none; + text-align: left; + font-size: 14px; + z-index: 100; + + /* outline radius for mozilla/firefox only */ + -moz-box-shadow: 0 0 10px #000; + -webkit-box-shadow: 0 0 10px #000; +} + +/* Override default breadcrumb style */ +/*.breadcrumb .bread-links { font-size: 12px; }*/ +#breadcrumb { + /*font-size: 13px;*/ + background-color: #DFDFDC; + background-repeat: repeat-x; + height: 28px; + line-height: 28px; + color: #888; + border: solid 1px #cacaca; + width: 100%; + overflow: hidden; + margin: 0px; + padding: 0px; +} + +#breadcrumb li { + list-style-type: none; + padding-left: 15px; + display: inline-block; + float: left; +} + +#breadcrumb a { + display: inline-block; + padding-right: 15px; + text-decoration: none; + outline: none; +} + +#breadcrumb .inactive { + color: black; +} + +/* Search form. + * http://www.webdesignerwall.com/demo/css3-search-form.html */ +.search_form { + display: inline-block; + zoom: 1; + /* ie7 hack for display:inline-block */ + *display: inline; + padding: 0px 0px; + /* + -webkit-box-shadow: 0 1px 0px rgba(0,0,0,.1); + -moz-box-shadow: 0 1px 0px rgba(0,0,0,.1); + box-shadow: 0 1px 0px rgba(0,0,0,.1); + */ +} + +.search_form .search_field { + line-height: 20px; + font-size: 13px; + border: solid 0px #bcbbbb; + outline: none; +} + +.wrapword { + white-space: -moz-pre-wrap !important; + /* Mozilla, since 1999 */ + white-space: -pre-wrap; + /* Opera 4-6 */ + white-space: -o-pre-wrap; + /* Opera 7 */ + white-space: pre-wrap; + /* css-3 */ + word-wrap: break-word; + /* Internet Explorer 5.5+ */ + white-space: -webkit-pre-wrap; + /* Newer versions of Chrome/Safari*/ + word-break: break-all; + white-space: normal; +} + +/* Labels shown in domain list page, used to identity domain backup mx and relay + * status. */ +.bgcolor-green { + background-color: green; +} + +.bgcolor-red { + background-color: red; +} + +.bgcolor-purple { + background-color: purple; +} + +.small_label { + padding: 0px 3px 0px 3px; + color: white; + -webkit-border-radius: 4px; + /* Webkit */ + -moz-border-radius: 4px; + /* Firefox */ + border-radius: 4px; + /* IE */ +} diff --git a/static/default/css/spectre-icons.min.css b/static/default/css/spectre-icons.min.css new file mode 100644 index 0000000..9b6167c --- /dev/null +++ b/static/default/css/spectre-icons.min.css @@ -0,0 +1 @@ +/*! Spectre.css Icons v0.5.8 | MIT License | github.com/picturepan2/spectre */.icon{box-sizing:border-box;display:inline-block;font-size:inherit;font-style:normal;height:1em;position:relative;text-indent:-9999px;vertical-align:middle;width:1em}.icon::after,.icon::before{content:"";display:block;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%)}.icon.icon-2x{font-size:1.6rem}.icon.icon-3x{font-size:2.4rem}.icon.icon-4x{font-size:3.2rem}.accordion .icon,.btn .icon,.menu .icon,.toast .icon{vertical-align:-10%}.btn-lg .icon{vertical-align:-15%}.icon-arrow-down::before,.icon-arrow-left::before,.icon-arrow-right::before,.icon-arrow-up::before,.icon-back::before,.icon-downward::before,.icon-forward::before,.icon-upward::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.65em;width:.65em}.icon-arrow-down::before{transform:translate(-50%,-75%) rotate(225deg)}.icon-arrow-left::before{transform:translate(-25%,-50%) rotate(-45deg)}.icon-arrow-right::before{transform:translate(-75%,-50%) rotate(135deg)}.icon-arrow-up::before{transform:translate(-50%,-25%) rotate(45deg)}.icon-back::after,.icon-forward::after{background:currentColor;height:.1rem;width:.8em}.icon-downward::after,.icon-upward::after{background:currentColor;height:.8em;width:.1rem}.icon-back::after{left:55%}.icon-back::before{transform:translate(-50%,-50%) rotate(-45deg)}.icon-downward::after{top:45%}.icon-downward::before{transform:translate(-50%,-50%) rotate(-135deg)}.icon-forward::after{left:45%}.icon-forward::before{transform:translate(-50%,-50%) rotate(135deg)}.icon-upward::after{top:55%}.icon-upward::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-caret::before{border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid currentColor;height:0;transform:translate(-50%,-25%);width:0}.icon-menu::before{background:currentColor;box-shadow:0 -.35em,0 .35em;height:.1rem;width:100%}.icon-apps::before{background:currentColor;box-shadow:-.35em -.35em,-.35em 0,-.35em .35em,0 -.35em,0 .35em,.35em -.35em,.35em 0,.35em .35em;height:3px;width:3px}.icon-resize-horiz::after,.icon-resize-horiz::before,.icon-resize-vert::after,.icon-resize-vert::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.45em;width:.45em}.icon-resize-horiz::before,.icon-resize-vert::before{transform:translate(-50%,-90%) rotate(45deg)}.icon-resize-horiz::after,.icon-resize-vert::after{transform:translate(-50%,-10%) rotate(225deg)}.icon-resize-horiz::before{transform:translate(-90%,-50%) rotate(-45deg)}.icon-resize-horiz::after{transform:translate(-10%,-50%) rotate(135deg)}.icon-more-horiz::before,.icon-more-vert::before{background:currentColor;border-radius:50%;box-shadow:-.4em 0,.4em 0;height:3px;width:3px}.icon-more-vert::before{box-shadow:0 -.4em,0 .4em}.icon-cross::before,.icon-minus::before,.icon-plus::before{background:currentColor;height:.1rem;width:100%}.icon-cross::after,.icon-plus::after{background:currentColor;height:100%;width:.1rem}.icon-cross::before{width:100%}.icon-cross::after{height:100%}.icon-cross::after,.icon-cross::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-check::before{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-75%) rotate(-45deg);width:.9em}.icon-stop{border:.1rem solid currentColor;border-radius:50%}.icon-stop::before{background:currentColor;height:.1rem;transform:translate(-50%,-50%) rotate(45deg);width:1em}.icon-shutdown{border:.1rem solid currentColor;border-radius:50%;border-top-color:transparent}.icon-shutdown::before{background:currentColor;content:"";height:.5em;top:.1em;width:.1rem}.icon-refresh::before{border:.1rem solid currentColor;border-radius:50%;border-right-color:transparent;height:1em;width:1em}.icon-refresh::after{border:.2em solid currentColor;border-left-color:transparent;border-top-color:transparent;height:0;left:80%;top:20%;width:0}.icon-search::before{border:.1rem solid currentColor;border-radius:50%;height:.75em;left:5%;top:5%;transform:translate(0,0) rotate(45deg);width:.75em}.icon-search::after{background:currentColor;height:.1rem;left:80%;top:80%;transform:translate(-50%,-50%) rotate(45deg);width:.4em}.icon-edit::before{border:.1rem solid currentColor;height:.4em;transform:translate(-40%,-60%) rotate(-45deg);width:.85em}.icon-edit::after{border:.15em solid currentColor;border-right-color:transparent;border-top-color:transparent;height:0;left:5%;top:95%;transform:translate(0,-100%);width:0}.icon-delete::before{border:.1rem solid currentColor;border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem;border-top:0;height:.75em;top:60%;width:.75em}.icon-delete::after{background:currentColor;box-shadow:-.25em .2em,.25em .2em;height:.1rem;top:.05rem;width:.5em}.icon-share{border:.1rem solid currentColor;border-radius:.1rem;border-right:0;border-top:0}.icon-share::before{border:.1rem solid currentColor;border-left:0;border-top:0;height:.4em;left:100%;top:.25em;transform:translate(-125%,-50%) rotate(-45deg);width:.4em}.icon-share::after{border:.1rem solid currentColor;border-bottom:0;border-radius:75% 0;border-right:0;height:.5em;width:.6em}.icon-flag::before{background:currentColor;height:1em;left:15%;width:.1rem}.icon-flag::after{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top-right-radius:.1rem;height:.65em;left:60%;top:35%;width:.8em}.icon-bookmark::before{border:.1rem solid currentColor;border-bottom:0;border-top-left-radius:.1rem;border-top-right-radius:.1rem;height:.9em;width:.8em}.icon-bookmark::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;border-radius:.1rem;height:.5em;transform:translate(-50%,35%) rotate(-45deg) skew(15deg,15deg);width:.5em}.icon-download,.icon-upload{border-bottom:.1rem solid currentColor}.icon-download::before,.icon-upload::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.5em;transform:translate(-50%,-60%) rotate(-135deg);width:.5em}.icon-download::after,.icon-upload::after{background:currentColor;height:.6em;top:40%;width:.1rem}.icon-upload::before{transform:translate(-50%,-60%) rotate(45deg)}.icon-upload::after{top:50%}.icon-copy::before{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0;height:.8em;left:40%;top:35%;width:.8em}.icon-copy::after{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;left:60%;top:60%;width:.8em}.icon-time{border:.1rem solid currentColor;border-radius:50%}.icon-time::before{background:currentColor;height:.4em;transform:translate(-50%,-75%);width:.1rem}.icon-time::after{background:currentColor;height:.3em;transform:translate(-50%,-75%) rotate(90deg);transform-origin:50% 90%;width:.1rem}.icon-mail::before{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;width:1em}.icon-mail::after{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-90%) rotate(-45deg) skew(10deg,10deg);width:.5em}.icon-people::before{border:.1rem solid currentColor;border-radius:50%;height:.45em;top:25%;width:.45em}.icon-people::after{border:.1rem solid currentColor;border-radius:50% 50% 0 0;height:.4em;top:75%;width:.9em}.icon-message{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0}.icon-message::before{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top:0;height:.8em;left:65%;top:40%;width:.7em}.icon-message::after{background:currentColor;border-radius:.1rem;height:.3em;left:10%;top:100%;transform:translate(0,-90%) rotate(45deg);width:.1rem}.icon-photo{border:.1rem solid currentColor;border-radius:.1rem}.icon-photo::before{border:.1rem solid currentColor;border-radius:50%;height:.25em;left:35%;top:35%;width:.25em}.icon-photo::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;height:.5em;left:60%;transform:translate(-50%,25%) rotate(-45deg);width:.5em}.icon-link::after,.icon-link::before{border:.1rem solid currentColor;border-radius:5em 0 0 5em;border-right:0;height:.5em;width:.75em}.icon-link::before{transform:translate(-70%,-45%) rotate(-45deg)}.icon-link::after{transform:translate(-30%,-55%) rotate(135deg)}.icon-location::before{border:.1rem solid currentColor;border-radius:50% 50% 50% 0;height:.8em;transform:translate(-50%,-60%) rotate(-45deg);width:.8em}.icon-location::after{border:.1rem solid currentColor;border-radius:50%;height:.2em;transform:translate(-50%,-80%);width:.2em}.icon-emoji{border:.1rem solid currentColor;border-radius:50%}.icon-emoji::before{border-radius:50%;box-shadow:-.17em -.1em,.17em -.1em;height:.15em;width:.15em}.icon-emoji::after{border:.1rem solid currentColor;border-bottom-color:transparent;border-radius:50%;border-right-color:transparent;height:.5em;transform:translate(-50%,-40%) rotate(-135deg);width:.5em} \ No newline at end of file diff --git a/static/default/css/spectre.min.css b/static/default/css/spectre.min.css new file mode 100644 index 0000000..9550fa4 --- /dev/null +++ b/static/default/css/spectre.min.css @@ -0,0 +1 @@ +/*! Spectre.css v0.5.8 | MIT License | github.com/picturepan2/spectre */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}hr{box-sizing:content-box;height:0;overflow:visible}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}address{font-style:normal}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:"SF Mono","Segoe UI Mono","Roboto Mono",Menlo,Courier,monospace;font-size:1em}dfn{font-style:italic}small{font-size:80%;font-weight:400}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}fieldset{border:0;margin:0;padding:0}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item;outline:0}canvas{display:inline-block}template{display:none}[hidden]{display:none}*,::after,::before{box-sizing:inherit}html{box-sizing:border-box;font-size:20px;line-height:1.5;-webkit-tap-highlight-color:transparent}body{background:#fff;color:#3b4351;font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;font-size:.8rem;overflow-x:hidden;text-rendering:optimizeLegibility}a{color:#5755d9;outline:0;text-decoration:none}a:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}a.active,a:active,a:focus,a:hover{color:#302ecd;text-decoration:underline}a:visited{color:#807fe2}h1,h2,h3,h4,h5,h6{color:inherit;font-weight:500;line-height:1.2;margin-bottom:.5em;margin-top:0}.h1,.h2,.h3,.h4,.h5,.h6{font-weight:500}.h1,h1{font-size:2rem}.h2,h2{font-size:1.6rem}.h3,h3{font-size:1.4rem}.h4,h4{font-size:1.2rem}.h5,h5{font-size:1rem}.h6,h6{font-size:.8rem}p{margin:0 0 1.2rem}a,ins,u{-webkit-text-decoration-skip:ink edges;text-decoration-skip:ink edges}abbr[title]{border-bottom:.05rem dotted;cursor:help;text-decoration:none}kbd{background:#303742;border-radius:.1rem;color:#fff;font-size:.7rem;line-height:1.25;padding:.1rem .2rem}mark{background:#ffe9b3;border-bottom:.05rem solid #ffd367;border-radius:.1rem;color:#3b4351;padding:.05rem .1rem 0}blockquote{border-left:.1rem solid #dadee4;margin-left:0;padding:.4rem .8rem}blockquote p:last-child{margin-bottom:0}ol,ul{margin:.8rem 0 .8rem .8rem;padding:0}ol ol,ol ul,ul ol,ul ul{margin:.8rem 0 .8rem .8rem}ol li,ul li{margin-top:.4rem}ul{list-style:disc inside}ul ul{list-style-type:circle}ol{list-style:decimal inside}ol ol{list-style-type:lower-alpha}dl dt{font-weight:700}dl dd{margin:.4rem 0 .8rem 0}.lang-zh,.lang-zh-hans,html:lang(zh),html:lang(zh-Hans){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",sans-serif}.lang-zh-hant,html:lang(zh-Hant){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang TC","Hiragino Sans CNS","Microsoft JhengHei","Helvetica Neue",sans-serif}.lang-ja,html:lang(ja){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans","Hiragino Kaku Gothic Pro","Yu Gothic",YuGothic,Meiryo,"Helvetica Neue",sans-serif}.lang-ko,html:lang(ko){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Helvetica Neue",sans-serif}.lang-cjk ins,.lang-cjk u,:lang(ja) ins,:lang(ja) u,:lang(zh) ins,:lang(zh) u{border-bottom:.05rem solid;text-decoration:none}.lang-cjk del+del,.lang-cjk del+s,.lang-cjk ins+ins,.lang-cjk ins+u,.lang-cjk s+del,.lang-cjk s+s,.lang-cjk u+ins,.lang-cjk u+u,:lang(ja) del+del,:lang(ja) del+s,:lang(ja) ins+ins,:lang(ja) ins+u,:lang(ja) s+del,:lang(ja) s+s,:lang(ja) u+ins,:lang(ja) u+u,:lang(zh) del+del,:lang(zh) del+s,:lang(zh) ins+ins,:lang(zh) ins+u,:lang(zh) s+del,:lang(zh) s+s,:lang(zh) u+ins,:lang(zh) u+u{margin-left:.125em}.table{border-collapse:collapse;border-spacing:0;text-align:left;width:100%}.table.table-striped tbody tr:nth-of-type(odd){background:#f7f8f9}.table tbody tr.active,.table.table-striped tbody tr.active{background:#eef0f3}.table.table-hover tbody tr:hover{background:#eef0f3}.table.table-scroll{display:block;overflow-x:auto;padding-bottom:.75rem;white-space:nowrap}.table td,.table th{border-bottom:.05rem solid #dadee4;padding:.6rem .4rem}.table th{border-bottom-width:.1rem}.btn{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #5755d9;border-radius:.1rem;color:#5755d9;cursor:pointer;display:inline-block;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;text-align:center;text-decoration:none;transition:background .2s,border .2s,box-shadow .2s,color .2s;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.btn:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.btn:focus,.btn:hover{background:#f1f1fc;border-color:#4b48d6;text-decoration:none}.btn.active,.btn:active{background:#4b48d6;border-color:#3634d2;color:#fff;text-decoration:none}.btn.active.loading::after,.btn:active.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.disabled,.btn:disabled,.btn[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn.btn-primary{background:#5755d9;border-color:#4b48d6;color:#fff}.btn.btn-primary:focus,.btn.btn-primary:hover{background:#4240d4;border-color:#3634d2;color:#fff}.btn.btn-primary.active,.btn.btn-primary:active{background:#3a38d2;border-color:#302ecd;color:#fff}.btn.btn-primary.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-success{background:#32b643;border-color:#2faa3f;color:#fff}.btn.btn-success:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.btn.btn-success:focus,.btn.btn-success:hover{background:#30ae40;border-color:#2da23c;color:#fff}.btn.btn-success.active,.btn.btn-success:active{background:#2a9a39;border-color:#278e34;color:#fff}.btn.btn-success.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-error{background:#e85600;border-color:#d95000;color:#fff}.btn.btn-error:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.btn.btn-error:focus,.btn.btn-error:hover{background:#de5200;border-color:#cf4d00;color:#fff}.btn.btn-error.active,.btn.btn-error:active{background:#c44900;border-color:#b54300;color:#fff}.btn.btn-error.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-link{background:0 0;border-color:transparent;color:#5755d9}.btn.btn-link.active,.btn.btn-link:active,.btn.btn-link:focus,.btn.btn-link:hover{color:#302ecd}.btn.btn-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.btn.btn-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.btn.btn-block{display:block;width:100%}.btn.btn-action{padding-left:0;padding-right:0;width:1.8rem}.btn.btn-action.btn-sm{width:1.4rem}.btn.btn-action.btn-lg{width:2rem}.btn.btn-clear{background:0 0;border:0;color:currentColor;height:1rem;line-height:.8rem;margin-left:.2rem;margin-right:-2px;opacity:1;padding:.1rem;text-decoration:none;width:1rem}.btn.btn-clear:focus,.btn.btn-clear:hover{background:rgba(247,248,249,.5);opacity:.95}.btn.btn-clear::before{content:"\2715"}.btn-group{display:inline-flex;display:-ms-inline-flexbox;-ms-flex-wrap:wrap;flex-wrap:wrap}.btn-group .btn{-ms-flex:1 0 auto;flex:1 0 auto}.btn-group .btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group .btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.btn-group .btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.btn-group .btn.active,.btn-group .btn:active,.btn-group .btn:focus,.btn-group .btn:hover{z-index:1}.btn-group.btn-group-block{display:flex;display:-ms-flexbox}.btn-group.btn-group-block .btn{-ms-flex:1 0 0;flex:1 0 0}.form-group:not(:last-child){margin-bottom:.4rem}fieldset{margin-bottom:.8rem}legend{font-size:.9rem;font-weight:500;margin-bottom:.8rem}.form-label{display:block;line-height:1.2rem;padding:.3rem 0}.form-label.label-sm{font-size:.7rem;padding:.1rem 0}.form-label.label-lg{font-size:.9rem;padding:.4rem 0}.form-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;background-image:none;border:.05rem solid #bcc3ce;border-radius:.1rem;color:#3b4351;display:block;font-size:.8rem;height:1.8rem;line-height:1.2rem;max-width:100%;outline:0;padding:.25rem .4rem;position:relative;transition:background .2s,border .2s,box-shadow .2s,color .2s;width:100%}.form-input:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-input::-webkit-input-placeholder{color:#bcc3ce}.form-input:-ms-input-placeholder{color:#bcc3ce}.form-input::-ms-input-placeholder{color:#bcc3ce}.form-input::placeholder{color:#bcc3ce}.form-input.input-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.form-input.input-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.form-input.input-inline{display:inline-block;vertical-align:middle;width:auto}.form-input[type=file]{height:auto}textarea.form-input,textarea.form-input.input-lg,textarea.form-input.input-sm{height:auto}.form-input-hint{color:#bcc3ce;font-size:.7rem;margin-top:.2rem}.has-success .form-input-hint,.is-success+.form-input-hint{color:#32b643}.has-error .form-input-hint,.is-error+.form-input-hint{color:#e85600}.form-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #bcc3ce;border-radius:.1rem;color:inherit;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;vertical-align:middle;width:100%}.form-select:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-select::-ms-expand{display:none}.form-select.select-sm{font-size:.7rem;height:1.4rem;padding:.05rem 1.1rem .05rem .3rem}.form-select.select-lg{font-size:.9rem;height:2rem;padding:.35rem 1.4rem .35rem .6rem}.form-select[multiple],.form-select[size]{height:auto;padding:.25rem .4rem}.form-select[multiple] option,.form-select[size] option{padding:.1rem .2rem}.form-select:not([multiple]):not([size]){background:#fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;padding-right:1.2rem}.has-icon-left,.has-icon-right{position:relative}.has-icon-left .form-icon,.has-icon-right .form-icon{height:.8rem;margin:0 .25rem;position:absolute;top:50%;transform:translateY(-50%);width:.8rem;z-index:2}.has-icon-left .form-icon{left:.05rem}.has-icon-left .form-input{padding-left:1.3rem}.has-icon-right .form-icon{right:.05rem}.has-icon-right .form-input{padding-right:1.3rem}.form-checkbox,.form-radio,.form-switch{display:block;line-height:1.2rem;margin:.2rem 0;min-height:1.4rem;padding:.1rem .4rem .1rem 1.2rem;position:relative}.form-checkbox input,.form-radio input,.form-switch input{clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;position:absolute;width:1px}.form-checkbox input:focus+.form-icon,.form-radio input:focus+.form-icon,.form-switch input:focus+.form-icon{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-checkbox input:checked+.form-icon,.form-radio input:checked+.form-icon,.form-switch input:checked+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox .form-icon,.form-radio .form-icon,.form-switch .form-icon{border:.05rem solid #bcc3ce;cursor:pointer;display:inline-block;position:absolute;transition:background .2s,border .2s,box-shadow .2s,color .2s}.form-checkbox.input-sm,.form-radio.input-sm,.form-switch.input-sm{font-size:.7rem;margin:0}.form-checkbox.input-lg,.form-radio.input-lg,.form-switch.input-lg{font-size:.9rem;margin:.3rem 0}.form-checkbox .form-icon,.form-radio .form-icon{background:#fff;height:.8rem;left:0;top:.3rem;width:.8rem}.form-checkbox input:active+.form-icon,.form-radio input:active+.form-icon{background:#eef0f3}.form-checkbox .form-icon{border-radius:.1rem}.form-checkbox input:checked+.form-icon::before{background-clip:padding-box;border:.1rem solid #fff;border-left-width:0;border-top-width:0;content:"";height:9px;left:50%;margin-left:-3px;margin-top:-6px;position:absolute;top:50%;transform:rotate(45deg);width:6px}.form-checkbox input:indeterminate+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox input:indeterminate+.form-icon::before{background:#fff;content:"";height:2px;left:50%;margin-left:-5px;margin-top:-1px;position:absolute;top:50%;width:10px}.form-radio .form-icon{border-radius:50%}.form-radio input:checked+.form-icon::before{background:#fff;border-radius:50%;content:"";height:6px;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:6px}.form-switch{padding-left:2rem}.form-switch .form-icon{background:#bcc3ce;background-clip:padding-box;border-radius:.45rem;height:.9rem;left:0;top:.25rem;width:1.6rem}.form-switch .form-icon::before{background:#fff;border-radius:50%;content:"";display:block;height:.8rem;left:0;position:absolute;top:0;transition:background .2s,border .2s,box-shadow .2s,color .2s,left .2s;width:.8rem}.form-switch input:checked+.form-icon::before{left:14px}.form-switch input:active+.form-icon::before{background:#f7f8f9}.input-group{display:flex;display:-ms-flexbox}.input-group .input-group-addon{background:#f7f8f9;border:.05rem solid #bcc3ce;border-radius:.1rem;line-height:1.2rem;padding:.25rem .4rem;white-space:nowrap}.input-group .input-group-addon.addon-sm{font-size:.7rem;padding:.05rem .3rem}.input-group .input-group-addon.addon-lg{font-size:.9rem;padding:.35rem .6rem}.input-group .form-input,.input-group .form-select{-ms-flex:1 1 auto;flex:1 1 auto;width:1%}.input-group .input-group-btn{z-index:1}.input-group .form-input:first-child:not(:last-child),.input-group .form-select:first-child:not(:last-child),.input-group .input-group-addon:first-child:not(:last-child),.input-group .input-group-btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group .form-input:not(:first-child):not(:last-child),.input-group .form-select:not(:first-child):not(:last-child),.input-group .input-group-addon:not(:first-child):not(:last-child),.input-group .input-group-btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.input-group .form-input:last-child:not(:first-child),.input-group .form-select:last-child:not(:first-child),.input-group .input-group-addon:last-child:not(:first-child),.input-group .input-group-btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.input-group .form-input:focus,.input-group .form-select:focus,.input-group .input-group-addon:focus,.input-group .input-group-btn:focus{z-index:2}.input-group .form-select{width:auto}.input-group.input-inline{display:inline-flex;display:-ms-inline-flexbox}.form-input.is-success,.form-select.is-success,.has-success .form-input,.has-success .form-select{background:#f9fdfa;border-color:#32b643}.form-input.is-success:focus,.form-select.is-success:focus,.has-success .form-input:focus,.has-success .form-select:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.form-input.is-error,.form-select.is-error,.has-error .form-input,.has-error .form-select{background:#fffaf7;border-color:#e85600}.form-input.is-error:focus,.form-select.is-error:focus,.has-error .form-input:focus,.has-error .form-select:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error .form-icon,.form-radio.is-error .form-icon,.form-switch.is-error .form-icon,.has-error .form-checkbox .form-icon,.has-error .form-radio .form-icon,.has-error .form-switch .form-icon{border-color:#e85600}.form-checkbox.is-error input:checked+.form-icon,.form-radio.is-error input:checked+.form-icon,.form-switch.is-error input:checked+.form-icon,.has-error .form-checkbox input:checked+.form-icon,.has-error .form-radio input:checked+.form-icon,.has-error .form-switch input:checked+.form-icon{background:#e85600;border-color:#e85600}.form-checkbox.is-error input:focus+.form-icon,.form-radio.is-error input:focus+.form-icon,.form-switch.is-error input:focus+.form-icon,.has-error .form-checkbox input:focus+.form-icon,.has-error .form-radio input:focus+.form-icon,.has-error .form-switch input:focus+.form-icon{border-color:#e85600;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error input:indeterminate+.form-icon,.has-error .form-checkbox input:indeterminate+.form-icon{background:#e85600;border-color:#e85600}.form-input:not(:placeholder-shown):invalid{border-color:#e85600}.form-input:not(:placeholder-shown):invalid:focus{background:#fffaf7;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-input:not(:placeholder-shown):invalid+.form-input-hint{color:#e85600}.form-input.disabled,.form-input:disabled,.form-select.disabled,.form-select:disabled{background-color:#eef0f3;cursor:not-allowed;opacity:.5}.form-input[readonly]{background-color:#f7f8f9}input.disabled+.form-icon,input:disabled+.form-icon{background:#eef0f3;cursor:not-allowed;opacity:.5}.form-switch input.disabled+.form-icon::before,.form-switch input:disabled+.form-icon::before{background:#fff}.form-horizontal{padding:.4rem 0}.form-horizontal .form-group{display:flex;display:-ms-flexbox;-ms-flex-wrap:wrap;flex-wrap:wrap}.form-inline{display:inline-block}.label{background:#eef0f3;border-radius:.1rem;color:#455060;display:inline-block;line-height:1.25;padding:.1rem .2rem}.label.label-rounded{border-radius:5rem;padding-left:.4rem;padding-right:.4rem}.label.label-primary{background:#5755d9;color:#fff}.label.label-secondary{background:#f1f1fc;color:#5755d9}.label.label-success{background:#32b643;color:#fff}.label.label-warning{background:#ffb700;color:#fff}.label.label-error{background:#e85600;color:#fff}code{background:#fcf2f2;border-radius:.1rem;color:#d73e48;font-size:85%;line-height:1.25;padding:.1rem .2rem}.code{border-radius:.1rem;color:#3b4351;position:relative}.code::before{color:#bcc3ce;content:attr(data-lang);font-size:.7rem;position:absolute;right:.4rem;top:.1rem}.code code{background:#f7f8f9;color:inherit;display:block;line-height:1.5;overflow-x:auto;padding:1rem;width:100%}.img-responsive{display:block;height:auto;max-width:100%}.img-fit-cover{object-fit:cover}.img-fit-contain{object-fit:contain}.video-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.video-responsive::before{content:"";display:block;padding-bottom:56.25%}.video-responsive embed,.video-responsive iframe,.video-responsive object{border:0;bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%}video.video-responsive{height:auto;max-width:100%}video.video-responsive::before{content:none}.video-responsive-4-3::before{padding-bottom:75%}.video-responsive-1-1::before{padding-bottom:100%}.figure{margin:0 0 .4rem 0}.figure .figure-caption{color:#66758c;margin-top:.4rem}.container{margin-left:auto;margin-right:auto;padding-left:.4rem;padding-right:.4rem;width:100%}.container.grid-xl{max-width:1296px}.container.grid-lg{max-width:976px}.container.grid-md{max-width:856px}.container.grid-sm{max-width:616px}.container.grid-xs{max-width:496px}.show-lg,.show-md,.show-sm,.show-xl,.show-xs{display:none!important}.columns{display:flex;display:-ms-flexbox;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-left:-.4rem;margin-right:-.4rem}.columns.col-gapless{margin-left:0;margin-right:0}.columns.col-gapless>.column{padding-left:0;padding-right:0}.columns.col-oneline{-ms-flex-wrap:nowrap;flex-wrap:nowrap;overflow-x:auto}.column{-ms-flex:1;flex:1;max-width:100%;padding-left:.4rem;padding-right:.4rem}.column.col-1,.column.col-10,.column.col-11,.column.col-12,.column.col-2,.column.col-3,.column.col-4,.column.col-5,.column.col-6,.column.col-7,.column.col-8,.column.col-9,.column.col-auto{-ms-flex:none;flex:none}.col-12{width:100%}.col-11{width:91.66666667%}.col-10{width:83.33333333%}.col-9{width:75%}.col-8{width:66.66666667%}.col-7{width:58.33333333%}.col-6{width:50%}.col-5{width:41.66666667%}.col-4{width:33.33333333%}.col-3{width:25%}.col-2{width:16.66666667%}.col-1{width:8.33333333%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;max-width:none;width:auto}.col-mx-auto{margin-left:auto;margin-right:auto}.col-ml-auto{margin-left:auto}.col-mr-auto{margin-right:auto}@media (max-width:1280px){.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{-ms-flex:none;flex:none}.col-xl-12{width:100%}.col-xl-11{width:91.66666667%}.col-xl-10{width:83.33333333%}.col-xl-9{width:75%}.col-xl-8{width:66.66666667%}.col-xl-7{width:58.33333333%}.col-xl-6{width:50%}.col-xl-5{width:41.66666667%}.col-xl-4{width:33.33333333%}.col-xl-3{width:25%}.col-xl-2{width:16.66666667%}.col-xl-1{width:8.33333333%}.col-xl-auto{width:auto}.hide-xl{display:none!important}.show-xl{display:block!important}}@media (max-width:960px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto{-ms-flex:none;flex:none}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-auto{width:auto}.hide-lg{display:none!important}.show-lg{display:block!important}}@media (max-width:840px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto{-ms-flex:none;flex:none}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-auto{width:auto}.hide-md{display:none!important}.show-md{display:block!important}}@media (max-width:600px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto{-ms-flex:none;flex:none}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-auto{width:auto}.hide-sm{display:none!important}.show-sm{display:block!important}}@media (max-width:480px){.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-auto{-ms-flex:none;flex:none}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-auto{width:auto}.hide-xs{display:none!important}.show-xs{display:block!important}}.hero{display:flex;display:-ms-flexbox;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:justify;justify-content:space-between;padding-bottom:4rem;padding-top:4rem}.hero.hero-sm{padding-bottom:2rem;padding-top:2rem}.hero.hero-lg{padding-bottom:8rem;padding-top:8rem}.hero .hero-body{padding:.4rem}.navbar{align-items:stretch;display:flex;display:-ms-flexbox;-ms-flex-align:stretch;-ms-flex-pack:justify;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:space-between}.navbar .navbar-section{align-items:center;display:flex;display:-ms-flexbox;-ms-flex:1 0 0;flex:1 0 0;-ms-flex-align:center}.navbar .navbar-section:not(:first-child):last-child{-ms-flex-pack:end;justify-content:flex-end}.navbar .navbar-center{align-items:center;display:flex;display:-ms-flexbox;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-align:center}.navbar .navbar-brand{font-size:.9rem;text-decoration:none}.accordion input:checked~.accordion-header .icon,.accordion[open] .accordion-header .icon{transform:rotate(90deg)}.accordion input:checked~.accordion-body,.accordion[open] .accordion-body{max-height:50rem}.accordion .accordion-header{display:block;padding:.2rem .4rem}.accordion .accordion-header .icon{transition:transform .25s}.accordion .accordion-body{margin-bottom:.4rem;max-height:0;overflow:hidden;transition:max-height .25s}summary.accordion-header::-webkit-details-marker{display:none}.avatar{background:#5755d9;border-radius:50%;color:rgba(255,255,255,.85);display:inline-block;font-size:.8rem;font-weight:300;height:1.6rem;line-height:1.25;margin:0;position:relative;vertical-align:middle;width:1.6rem}.avatar.avatar-xs{font-size:.4rem;height:.8rem;width:.8rem}.avatar.avatar-sm{font-size:.6rem;height:1.2rem;width:1.2rem}.avatar.avatar-lg{font-size:1.2rem;height:2.4rem;width:2.4rem}.avatar.avatar-xl{font-size:1.6rem;height:3.2rem;width:3.2rem}.avatar img{border-radius:50%;height:100%;position:relative;width:100%;z-index:1}.avatar .avatar-icon,.avatar .avatar-presence{background:#fff;bottom:14.64%;height:50%;padding:.1rem;position:absolute;right:14.64%;transform:translate(50%,50%);width:50%;z-index:2}.avatar .avatar-presence{background:#bcc3ce;border-radius:50%;box-shadow:0 0 0 .1rem #fff;height:.5em;width:.5em}.avatar .avatar-presence.online{background:#32b643}.avatar .avatar-presence.busy{background:#e85600}.avatar .avatar-presence.away{background:#ffb700}.avatar[data-initial]::before{color:currentColor;content:attr(data-initial);left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);z-index:1}.badge{position:relative;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge]::after{background:#5755d9;background-clip:padding-box;border-radius:.5rem;box-shadow:0 0 0 .1rem #fff;color:#fff;content:attr(data-badge);display:inline-block;transform:translate(-.05rem,-.5rem)}.badge[data-badge]::after{font-size:.7rem;height:.9rem;line-height:1;min-width:.9rem;padding:.1rem .2rem;text-align:center;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge=""]::after{height:6px;min-width:6px;padding:0;width:6px}.badge.btn::after{position:absolute;right:0;top:0;transform:translate(50%,-50%)}.badge.avatar::after{position:absolute;right:14.64%;top:14.64%;transform:translate(50%,-50%);z-index:100}.breadcrumb{list-style:none;margin:.2rem 0;padding:.2rem 0}.breadcrumb .breadcrumb-item{color:#66758c;display:inline-block;margin:0;padding:.2rem 0}.breadcrumb .breadcrumb-item:not(:last-child){margin-right:.2rem}.breadcrumb .breadcrumb-item:not(:last-child) a{color:#66758c}.breadcrumb .breadcrumb-item:not(:first-child)::before{color:#66758c;content:"/";padding-right:.4rem}.bar{background:#eef0f3;border-radius:.1rem;display:flex;display:-ms-flexbox;-ms-flex-wrap:nowrap;flex-wrap:nowrap;height:.8rem;width:100%}.bar.bar-sm{height:.2rem}.bar .bar-item{background:#5755d9;color:#fff;display:block;-ms-flex-negative:0;flex-shrink:0;font-size:.7rem;height:100%;line-height:.8rem;position:relative;text-align:center;width:0}.bar .bar-item:first-child{border-bottom-left-radius:.1rem;border-top-left-radius:.1rem}.bar .bar-item:last-child{border-bottom-right-radius:.1rem;border-top-right-radius:.1rem;-ms-flex-negative:1;flex-shrink:1}.bar-slider{height:.1rem;margin:.4rem 0;position:relative}.bar-slider .bar-item{left:0;padding:0;position:absolute}.bar-slider .bar-item:not(:last-child):first-child{background:#eef0f3;z-index:1}.bar-slider .bar-slider-btn{background:#5755d9;border:0;border-radius:50%;height:.6rem;padding:0;position:absolute;right:0;top:50%;transform:translate(50%,-50%);width:.6rem}.bar-slider .bar-slider-btn:active{box-shadow:0 0 0 .1rem #5755d9}.card{background:#fff;border:.05rem solid #dadee4;border-radius:.1rem;display:flex;display:-ms-flexbox;-ms-flex-direction:column;flex-direction:column}.card .card-body,.card .card-footer,.card .card-header{padding:.8rem;padding-bottom:0}.card .card-body:last-child,.card .card-footer:last-child,.card .card-header:last-child{padding-bottom:.8rem}.card .card-body{-ms-flex:1 1 auto;flex:1 1 auto}.card .card-image{padding-top:.8rem}.card .card-image:first-child{padding-top:0}.card .card-image:first-child img{border-top-left-radius:.1rem;border-top-right-radius:.1rem}.card .card-image:last-child img{border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem}.chip{align-items:center;background:#eef0f3;border-radius:5rem;display:inline-flex;display:-ms-inline-flexbox;-ms-flex-align:center;font-size:90%;height:1.2rem;line-height:.8rem;margin:.1rem;max-width:320px;overflow:hidden;padding:.2rem .4rem;text-decoration:none;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}.chip.active{background:#5755d9;color:#fff}.chip .avatar{margin-left:-.4rem;margin-right:.2rem}.chip .btn-clear{border-radius:50%;transform:scale(.75)}.dropdown{display:inline-block;position:relative}.dropdown .menu{animation:slide-down .15s ease 1;display:none;left:0;max-height:50vh;overflow-y:auto;position:absolute;top:100%}.dropdown.dropdown-right .menu{left:auto;right:0}.dropdown .dropdown-toggle:focus+.menu,.dropdown .menu:hover,.dropdown.active .menu{display:block}.dropdown .btn-group .dropdown-toggle:nth-last-child(2){border-bottom-right-radius:.1rem;border-top-right-radius:.1rem}.empty{background:#f7f8f9;border-radius:.1rem;color:#66758c;padding:3.2rem 1.6rem;text-align:center}.empty .empty-icon{margin-bottom:.8rem}.empty .empty-subtitle,.empty .empty-title{margin:.4rem auto}.empty .empty-action{margin-top:.8rem}.menu{background:#fff;border-radius:.1rem;box-shadow:0 .05rem .2rem rgba(48,55,66,.3);list-style:none;margin:0;min-width:180px;padding:.4rem;transform:translateY(.2rem);z-index:300}.menu.menu-nav{background:0 0;box-shadow:none}.menu .menu-item{margin-top:0;padding:0 .4rem;position:relative;text-decoration:none}.menu .menu-item>a{border-radius:.1rem;color:inherit;display:block;margin:0 -.4rem;padding:.2rem .4rem;text-decoration:none}.menu .menu-item>a:focus,.menu .menu-item>a:hover{background:#f1f1fc;color:#5755d9}.menu .menu-item>a.active,.menu .menu-item>a:active{background:#f1f1fc;color:#5755d9}.menu .menu-item .form-checkbox,.menu .menu-item .form-radio,.menu .menu-item .form-switch{margin:.1rem 0}.menu .menu-item+.menu-item{margin-top:.2rem}.menu .menu-badge{align-items:center;display:flex;display:-ms-flexbox;-ms-flex-align:center;height:100%;position:absolute;right:0;top:0}.menu .menu-badge .label{margin-right:.4rem}.modal{align-items:center;bottom:0;display:none;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center;left:0;opacity:0;overflow:hidden;padding:.4rem;position:fixed;right:0;top:0}.modal.active,.modal:target{display:flex;display:-ms-flexbox;opacity:1;z-index:400}.modal.active .modal-overlay,.modal:target .modal-overlay{background:rgba(247,248,249,.75);bottom:0;cursor:default;display:block;left:0;position:absolute;right:0;top:0}.modal.active .modal-container,.modal:target .modal-container{animation:slide-down .2s ease 1;z-index:1}.modal.modal-sm .modal-container{max-width:320px;padding:0 .4rem}.modal.modal-lg .modal-overlay{background:#fff}.modal.modal-lg .modal-container{box-shadow:none;max-width:960px}.modal-container{background:#fff;border-radius:.1rem;box-shadow:0 .2rem .5rem rgba(48,55,66,.3);display:flex;display:-ms-flexbox;-ms-flex-direction:column;flex-direction:column;max-height:75vh;max-width:640px;padding:0 .8rem;width:100%}.modal-container.modal-fullheight{max-height:100vh}.modal-container .modal-header{color:#303742;padding:.8rem}.modal-container .modal-body{overflow-y:auto;padding:.8rem;position:relative}.modal-container .modal-footer{padding:.8rem;text-align:right}.nav{display:flex;display:-ms-flexbox;-ms-flex-direction:column;flex-direction:column;list-style:none;margin:.2rem 0}.nav .nav-item a{color:#66758c;padding:.2rem .4rem;text-decoration:none}.nav .nav-item a:focus,.nav .nav-item a:hover{color:#5755d9}.nav .nav-item.active>a{color:#505c6e;font-weight:700}.nav .nav-item.active>a:focus,.nav .nav-item.active>a:hover{color:#5755d9}.nav .nav{margin-bottom:.4rem;margin-left:.8rem}.pagination{display:flex;display:-ms-flexbox;list-style:none;margin:.2rem 0;padding:.2rem 0}.pagination .page-item{margin:.2rem .05rem}.pagination .page-item span{display:inline-block;padding:.2rem .2rem}.pagination .page-item a{border-radius:.1rem;display:inline-block;padding:.2rem .4rem;text-decoration:none}.pagination .page-item a:focus,.pagination .page-item a:hover{color:#5755d9}.pagination .page-item.disabled a{cursor:default;opacity:.5;pointer-events:none}.pagination .page-item.active a{background:#5755d9;color:#fff}.pagination .page-item.page-next,.pagination .page-item.page-prev{-ms-flex:1 0 50%;flex:1 0 50%}.pagination .page-item.page-next{text-align:right}.pagination .page-item .page-item-title{margin:0}.pagination .page-item .page-item-subtitle{margin:0;opacity:.5}.panel{border:.05rem solid #dadee4;border-radius:.1rem;display:flex;display:-ms-flexbox;-ms-flex-direction:column;flex-direction:column}.panel .panel-footer,.panel .panel-header{-ms-flex:0 0 auto;flex:0 0 auto;padding:.8rem}.panel .panel-nav{-ms-flex:0 0 auto;flex:0 0 auto}.panel .panel-body{-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;padding:0 .8rem}.popover{display:inline-block;position:relative}.popover .popover-container{left:50%;opacity:0;padding:.4rem;position:absolute;top:0;transform:translate(-50%,-50%) scale(0);transition:transform .2s;width:320px;z-index:300}.popover :focus+.popover-container,.popover:hover .popover-container{display:block;opacity:1;transform:translate(-50%,-100%) scale(1)}.popover.popover-right .popover-container{left:100%;top:50%}.popover.popover-right :focus+.popover-container,.popover.popover-right:hover .popover-container{transform:translate(0,-50%) scale(1)}.popover.popover-bottom .popover-container{left:50%;top:100%}.popover.popover-bottom :focus+.popover-container,.popover.popover-bottom:hover .popover-container{transform:translate(-50%,0) scale(1)}.popover.popover-left .popover-container{left:0;top:50%}.popover.popover-left :focus+.popover-container,.popover.popover-left:hover .popover-container{transform:translate(-100%,-50%) scale(1)}.popover .card{border:0;box-shadow:0 .2rem .5rem rgba(48,55,66,.3)}.step{display:flex;display:-ms-flexbox;-ms-flex-wrap:nowrap;flex-wrap:nowrap;list-style:none;margin:.2rem 0;width:100%}.step .step-item{-ms-flex:1 1 0;flex:1 1 0;margin-top:0;min-height:1rem;position:relative;text-align:center}.step .step-item:not(:first-child)::before{background:#5755d9;content:"";height:2px;left:-50%;position:absolute;top:9px;width:100%}.step .step-item a{color:#5755d9;display:inline-block;padding:20px 10px 0;text-decoration:none}.step .step-item a::before{background:#5755d9;border:.1rem solid #fff;border-radius:50%;content:"";display:block;height:.6rem;left:50%;position:absolute;top:.2rem;transform:translateX(-50%);width:.6rem;z-index:1}.step .step-item.active a::before{background:#fff;border:.1rem solid #5755d9}.step .step-item.active~.step-item::before{background:#dadee4}.step .step-item.active~.step-item a{color:#bcc3ce}.step .step-item.active~.step-item a::before{background:#dadee4}.tab{align-items:center;border-bottom:.05rem solid #dadee4;display:flex;display:-ms-flexbox;-ms-flex-align:center;-ms-flex-wrap:wrap;flex-wrap:wrap;list-style:none;margin:.2rem 0 .15rem 0}.tab .tab-item{margin-top:0}.tab .tab-item a{border-bottom:.1rem solid transparent;color:inherit;display:block;margin:0 .4rem 0 0;padding:.4rem .2rem .3rem .2rem;text-decoration:none}.tab .tab-item a:focus,.tab .tab-item a:hover{color:#5755d9}.tab .tab-item a.active,.tab .tab-item.active a{border-bottom-color:#5755d9;color:#5755d9}.tab .tab-item.tab-action{-ms-flex:1 0 auto;flex:1 0 auto;text-align:right}.tab .tab-item .btn-clear{margin-top:-.2rem}.tab.tab-block .tab-item{-ms-flex:1 0 0;flex:1 0 0;text-align:center}.tab.tab-block .tab-item a{margin:0}.tab.tab-block .tab-item .badge[data-badge]::after{position:absolute;right:.1rem;top:.1rem;transform:translate(0,0)}.tab:not(.tab-block) .badge{padding-right:0}.tile{align-content:space-between;align-items:flex-start;display:flex;display:-ms-flexbox;-ms-flex-align:start;-ms-flex-line-pack:justify}.tile .tile-action,.tile .tile-icon{-ms-flex:0 0 auto;flex:0 0 auto}.tile .tile-content{-ms-flex:1 1 auto;flex:1 1 auto}.tile .tile-content:not(:first-child){padding-left:.4rem}.tile .tile-content:not(:last-child){padding-right:.4rem}.tile .tile-subtitle,.tile .tile-title{line-height:1.2rem}.tile.tile-centered{align-items:center;-ms-flex-align:center}.tile.tile-centered .tile-content{overflow:hidden}.tile.tile-centered .tile-subtitle,.tile.tile-centered .tile-title{margin-bottom:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast{background:rgba(48,55,66,.95);border:.05rem solid #303742;border-color:#303742;border-radius:.1rem;color:#fff;display:block;padding:.4rem;width:100%}.toast.toast-primary{background:rgba(87,85,217,.95);border-color:#5755d9}.toast.toast-success{background:rgba(50,182,67,.95);border-color:#32b643}.toast.toast-warning{background:rgba(255,183,0,.95);border-color:#ffb700}.toast.toast-error{background:rgba(232,86,0,.95);border-color:#e85600}.toast a{color:#fff;text-decoration:underline}.toast a.active,.toast a:active,.toast a:focus,.toast a:hover{opacity:.75}.toast .btn-clear{margin:.1rem}.toast p:last-child{margin-bottom:0}.tooltip{position:relative}.tooltip::after{background:rgba(48,55,66,.95);border-radius:.1rem;bottom:100%;color:#fff;content:attr(data-tooltip);display:block;font-size:.7rem;left:50%;max-width:320px;opacity:0;overflow:hidden;padding:.2rem .4rem;pointer-events:none;position:absolute;text-overflow:ellipsis;transform:translate(-50%,.4rem);transition:opacity .2s,transform .2s;white-space:pre;z-index:300}.tooltip:focus::after,.tooltip:hover::after{opacity:1;transform:translate(-50%,-.2rem)}.tooltip.disabled,.tooltip[disabled]{pointer-events:auto}.tooltip.tooltip-right::after{bottom:50%;left:100%;transform:translate(-.2rem,50%)}.tooltip.tooltip-right:focus::after,.tooltip.tooltip-right:hover::after{transform:translate(.2rem,50%)}.tooltip.tooltip-bottom::after{bottom:auto;top:100%;transform:translate(-50%,-.4rem)}.tooltip.tooltip-bottom:focus::after,.tooltip.tooltip-bottom:hover::after{transform:translate(-50%,.2rem)}.tooltip.tooltip-left::after{bottom:50%;left:auto;right:100%;transform:translate(.4rem,50%)}.tooltip.tooltip-left:focus::after,.tooltip.tooltip-left:hover::after{transform:translate(-.2rem,50%)}@keyframes loading{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}@keyframes slide-down{0%{opacity:0;transform:translateY(-1.6rem)}100%{opacity:1;transform:translateY(0)}}.text-primary{color:#5755d9!important}a.text-primary:focus,a.text-primary:hover{color:#4240d4}a.text-primary:visited{color:#6c6ade}.text-secondary{color:#e5e5f9!important}a.text-secondary:focus,a.text-secondary:hover{color:#d1d0f4}a.text-secondary:visited{color:#fafafe}.text-gray{color:#bcc3ce!important}a.text-gray:focus,a.text-gray:hover{color:#adb6c4}a.text-gray:visited{color:#cbd0d9}.text-light{color:#fff!important}a.text-light:focus,a.text-light:hover{color:#f2f2f2}a.text-light:visited{color:#fff}.text-dark{color:#3b4351!important}a.text-dark:focus,a.text-dark:hover{color:#303742}a.text-dark:visited{color:#455060}.text-success{color:#32b643!important}a.text-success:focus,a.text-success:hover{color:#2da23c}a.text-success:visited{color:#39c94b}.text-warning{color:#ffb700!important}a.text-warning:focus,a.text-warning:hover{color:#e6a500}a.text-warning:visited{color:#ffbe1a}.text-error{color:#e85600!important}a.text-error:focus,a.text-error:hover{color:#cf4d00}a.text-error:visited{color:#ff6003}.bg-primary{background:#5755d9!important;color:#fff}.bg-secondary{background:#f1f1fc!important}.bg-dark{background:#303742!important;color:#fff}.bg-gray{background:#f7f8f9!important}.bg-success{background:#32b643!important;color:#fff}.bg-warning{background:#ffb700!important;color:#fff}.bg-error{background:#e85600!important;color:#fff}.c-hand{cursor:pointer}.c-move{cursor:move}.c-zoom-in{cursor:zoom-in}.c-zoom-out{cursor:zoom-out}.c-not-allowed{cursor:not-allowed}.c-auto{cursor:auto}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:flex;display:-ms-flexbox}.d-inline-flex{display:inline-flex;display:-ms-inline-flexbox}.d-hide,.d-none{display:none!important}.d-visible{visibility:visible}.d-invisible{visibility:hidden}.text-hide{background:0 0;border:0;color:transparent;font-size:0;line-height:0;text-shadow:none}.text-assistive{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.divider,.divider-vert{display:block;position:relative}.divider-vert[data-content]::after,.divider[data-content]::after{background:#fff;color:#bcc3ce;content:attr(data-content);display:inline-block;font-size:.7rem;padding:0 .4rem;transform:translateY(-.65rem)}.divider{border-top:.05rem solid #f1f3f5;height:.05rem;margin:.4rem 0}.divider[data-content]{margin:.8rem 0}.divider-vert{display:block;padding:.8rem}.divider-vert::before{border-left:.05rem solid #dadee4;bottom:.4rem;content:"";display:block;left:50%;position:absolute;top:.4rem;transform:translateX(-50%)}.divider-vert[data-content]::after{left:50%;padding:.2rem 0;position:absolute;top:50%;transform:translate(-50%,-50%)}.loading{color:transparent!important;min-height:.8rem;pointer-events:none;position:relative}.loading::after{animation:loading .5s infinite linear;border:.1rem solid #5755d9;border-radius:50%;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:.8rem;left:50%;margin-left:-.4rem;margin-top:-.4rem;position:absolute;top:50%;width:.8rem;z-index:1}.loading.loading-lg{min-height:2rem}.loading.loading-lg::after{height:1.6rem;margin-left:-.8rem;margin-top:-.8rem;width:1.6rem}.clearfix::after{clear:both;content:"";display:table}.float-left{float:left!important}.float-right{float:right!important}.p-relative{position:relative!important}.p-absolute{position:absolute!important}.p-fixed{position:fixed!important}.p-sticky{position:sticky!important;position:-webkit-sticky!important}.p-centered{display:block;float:none;margin-left:auto;margin-right:auto}.flex-centered{align-items:center;display:flex;display:-ms-flexbox;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center}.m-0{margin:0!important}.mb-0{margin-bottom:0!important}.ml-0{margin-left:0!important}.mr-0{margin-right:0!important}.mt-0{margin-top:0!important}.mx-0{margin-left:0!important;margin-right:0!important}.my-0{margin-bottom:0!important;margin-top:0!important}.m-1{margin:.2rem!important}.mb-1{margin-bottom:.2rem!important}.ml-1{margin-left:.2rem!important}.mr-1{margin-right:.2rem!important}.mt-1{margin-top:.2rem!important}.mx-1{margin-left:.2rem!important;margin-right:.2rem!important}.my-1{margin-bottom:.2rem!important;margin-top:.2rem!important}.m-2{margin:.4rem!important}.mb-2{margin-bottom:.4rem!important}.ml-2{margin-left:.4rem!important}.mr-2{margin-right:.4rem!important}.mt-2{margin-top:.4rem!important}.mx-2{margin-left:.4rem!important;margin-right:.4rem!important}.my-2{margin-bottom:.4rem!important;margin-top:.4rem!important}.p-0{padding:0!important}.pb-0{padding-bottom:0!important}.pl-0{padding-left:0!important}.pr-0{padding-right:0!important}.pt-0{padding-top:0!important}.px-0{padding-left:0!important;padding-right:0!important}.py-0{padding-bottom:0!important;padding-top:0!important}.p-1{padding:.2rem!important}.pb-1{padding-bottom:.2rem!important}.pl-1{padding-left:.2rem!important}.pr-1{padding-right:.2rem!important}.pt-1{padding-top:.2rem!important}.px-1{padding-left:.2rem!important;padding-right:.2rem!important}.py-1{padding-bottom:.2rem!important;padding-top:.2rem!important}.p-2{padding:.4rem!important}.pb-2{padding-bottom:.4rem!important}.pl-2{padding-left:.4rem!important}.pr-2{padding-right:.4rem!important}.pt-2{padding-top:.4rem!important}.px-2{padding-left:.4rem!important;padding-right:.4rem!important}.py-2{padding-bottom:.4rem!important;padding-top:.4rem!important}.s-rounded{border-radius:.1rem}.s-circle{border-radius:50%}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-normal{font-weight:400}.text-bold{font-weight:700}.text-italic{font-style:italic}.text-large{font-size:1.2em}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{overflow:hidden;text-overflow:clip;white-space:nowrap}.text-break{-webkit-hyphens:auto;-ms-hyphens:auto;hyphens:auto;word-break:break-word;word-wrap:break-word} \ No newline at end of file diff --git a/static/default/images/arrow_left_off.png b/static/default/images/arrow_left_off.png new file mode 100644 index 0000000..8c64108 Binary files /dev/null and b/static/default/images/arrow_left_off.png differ diff --git a/static/default/images/arrow_leftend_off.png b/static/default/images/arrow_leftend_off.png new file mode 100644 index 0000000..76891ae Binary files /dev/null and b/static/default/images/arrow_leftend_off.png differ diff --git a/static/default/images/arrow_right_off.png b/static/default/images/arrow_right_off.png new file mode 100644 index 0000000..051509b Binary files /dev/null and b/static/default/images/arrow_right_off.png differ diff --git a/static/default/images/arrow_right_ovr.png b/static/default/images/arrow_right_ovr.png new file mode 100644 index 0000000..15f508b Binary files /dev/null and b/static/default/images/arrow_right_ovr.png differ diff --git a/static/default/images/arrow_sm_black.gif b/static/default/images/arrow_sm_black.gif new file mode 100644 index 0000000..ff5f050 Binary files /dev/null and b/static/default/images/arrow_sm_black.gif differ diff --git a/static/default/images/arrow_sm_grey.gif b/static/default/images/arrow_sm_grey.gif new file mode 100644 index 0000000..50ec4cb Binary files /dev/null and b/static/default/images/arrow_sm_grey.gif differ diff --git a/static/default/images/ball_grey_16.png b/static/default/images/ball_grey_16.png new file mode 100644 index 0000000..ddb7e5f Binary files /dev/null and b/static/default/images/ball_grey_16.png differ diff --git a/static/default/images/ball_yellow_13.png b/static/default/images/ball_yellow_13.png new file mode 100644 index 0000000..ba4a79d Binary files /dev/null and b/static/default/images/ball_yellow_13.png differ diff --git a/static/default/images/bck_black_70.png b/static/default/images/bck_black_70.png new file mode 100644 index 0000000..8c78012 Binary files /dev/null and b/static/default/images/bck_black_70.png differ diff --git a/static/default/images/bck_main.png b/static/default/images/bck_main.png new file mode 100644 index 0000000..f250205 Binary files /dev/null and b/static/default/images/bck_main.png differ diff --git a/static/default/images/bck_white_10.png b/static/default/images/bck_white_10.png new file mode 100644 index 0000000..3e2664d Binary files /dev/null and b/static/default/images/bck_white_10.png differ diff --git a/static/default/images/bck_white_50.png b/static/default/images/bck_white_50.png new file mode 100644 index 0000000..0979144 Binary files /dev/null and b/static/default/images/bck_white_50.png differ diff --git a/static/default/images/bck_white_75.png b/static/default/images/bck_white_75.png new file mode 100644 index 0000000..7d67d6a Binary files /dev/null and b/static/default/images/bck_white_75.png differ diff --git a/static/default/images/button_glas2.png b/static/default/images/button_glas2.png new file mode 100644 index 0000000..8ff97e5 Binary files /dev/null and b/static/default/images/button_glas2.png differ diff --git a/static/default/images/fancybox/blank.gif b/static/default/images/fancybox/blank.gif new file mode 100644 index 0000000..35d42e8 Binary files /dev/null and b/static/default/images/fancybox/blank.gif differ diff --git a/static/default/images/fancybox/fancybox-x.png b/static/default/images/fancybox/fancybox-x.png new file mode 100644 index 0000000..c2130f8 Binary files /dev/null and b/static/default/images/fancybox/fancybox-x.png differ diff --git a/static/default/images/fancybox/fancybox-y.png b/static/default/images/fancybox/fancybox-y.png new file mode 100644 index 0000000..7ef399b Binary files /dev/null and b/static/default/images/fancybox/fancybox-y.png differ diff --git a/static/default/images/fancybox/fancybox.blank.gif b/static/default/images/fancybox/fancybox.blank.gif new file mode 100644 index 0000000..35d42e8 Binary files /dev/null and b/static/default/images/fancybox/fancybox.blank.gif differ diff --git a/static/default/images/fancybox/fancybox.png b/static/default/images/fancybox/fancybox.png new file mode 100644 index 0000000..65e14f6 Binary files /dev/null and b/static/default/images/fancybox/fancybox.png differ diff --git a/static/default/images/graph_16.png b/static/default/images/graph_16.png new file mode 100644 index 0000000..ba5d9a5 Binary files /dev/null and b/static/default/images/graph_16.png differ diff --git a/static/default/images/ico_close_ovr.png b/static/default/images/ico_close_ovr.png new file mode 100644 index 0000000..4537209 Binary files /dev/null and b/static/default/images/ico_close_ovr.png differ diff --git a/static/default/images/members.png b/static/default/images/members.png new file mode 100644 index 0000000..b148d54 Binary files /dev/null and b/static/default/images/members.png differ diff --git a/static/default/images/page_active.gif b/static/default/images/page_active.gif new file mode 100644 index 0000000..2d753ea Binary files /dev/null and b/static/default/images/page_active.gif differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..a86b366 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/fontawesome/css/fontawesome-all.min.css b/static/fontawesome/css/fontawesome-all.min.css new file mode 100644 index 0000000..3d28ab2 --- /dev/null +++ b/static/fontawesome/css/fontawesome-all.min.css @@ -0,0 +1,5 @@ +/*! + * Font Awesome Free 5.13.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\f95b"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\f952"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-faucet:before{content:"\f905"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\f907"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\f95c"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\f95d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-hands-wash:before{content:"\f95e"}.fa-handshake:before{content:"\f2b5"}.fa-handshake-alt-slash:before{content:"\f95f"}.fa-handshake-slash:before{content:"\f960"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-head-side-cough:before{content:"\f961"}.fa-head-side-cough-slash:before{content:"\f962"}.fa-head-side-mask:before{content:"\f963"}.fa-head-side-virus:before{content:"\f964"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-house-user:before{content:"\f965"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\f913"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\f955"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-house:before{content:"\f966"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\f967"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\f91a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\f956"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-arrows:before{content:"\f968"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\f91e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\f969"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pump-medical:before{content:"\f96a"}.fa-pump-soap:before{content:"\f96b"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-shield-virus:before{content:"\f96c"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopify:before{content:"\f957"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\f96e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\f96f"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-store-alt-slash:before{content:"\f970"}.fa-store-slash:before{content:"\f971"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\f972"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\f941"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\f949"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-virus:before{content:"\f974"}.fa-virus-slash:before{content:"\f975"}.fa-viruses:before{content:"\f976"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.fab,.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900} \ No newline at end of file diff --git a/static/fontawesome/webfonts/fa-brands-400.eot b/static/fontawesome/webfonts/fa-brands-400.eot new file mode 100644 index 0000000..a1bc094 Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.eot differ diff --git a/static/fontawesome/webfonts/fa-brands-400.svg b/static/fontawesome/webfonts/fa-brands-400.svg new file mode 100644 index 0000000..46ad237 --- /dev/null +++ b/static/fontawesome/webfonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/fontawesome/webfonts/fa-brands-400.ttf b/static/fontawesome/webfonts/fa-brands-400.ttf new file mode 100644 index 0000000..948a2a6 Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.ttf differ diff --git a/static/fontawesome/webfonts/fa-brands-400.woff b/static/fontawesome/webfonts/fa-brands-400.woff new file mode 100644 index 0000000..2a89d52 Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.woff differ diff --git a/static/fontawesome/webfonts/fa-brands-400.woff2 b/static/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..141a90a Binary files /dev/null and b/static/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/static/fontawesome/webfonts/fa-regular-400.eot b/static/fontawesome/webfonts/fa-regular-400.eot new file mode 100644 index 0000000..38cf251 Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.eot differ diff --git a/static/fontawesome/webfonts/fa-regular-400.svg b/static/fontawesome/webfonts/fa-regular-400.svg new file mode 100644 index 0000000..48634a9 --- /dev/null +++ b/static/fontawesome/webfonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/fontawesome/webfonts/fa-regular-400.ttf b/static/fontawesome/webfonts/fa-regular-400.ttf new file mode 100644 index 0000000..abe99e2 Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.ttf differ diff --git a/static/fontawesome/webfonts/fa-regular-400.woff b/static/fontawesome/webfonts/fa-regular-400.woff new file mode 100644 index 0000000..24de566 Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.woff differ diff --git a/static/fontawesome/webfonts/fa-regular-400.woff2 b/static/fontawesome/webfonts/fa-regular-400.woff2 new file mode 100644 index 0000000..7e0118e Binary files /dev/null and b/static/fontawesome/webfonts/fa-regular-400.woff2 differ diff --git a/static/fontawesome/webfonts/fa-solid-900.eot b/static/fontawesome/webfonts/fa-solid-900.eot new file mode 100644 index 0000000..d3b77c2 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.eot differ diff --git a/static/fontawesome/webfonts/fa-solid-900.svg b/static/fontawesome/webfonts/fa-solid-900.svg new file mode 100644 index 0000000..7742838 --- /dev/null +++ b/static/fontawesome/webfonts/fa-solid-900.svg @@ -0,0 +1,4938 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/fontawesome/webfonts/fa-solid-900.ttf b/static/fontawesome/webfonts/fa-solid-900.ttf new file mode 100644 index 0000000..5b97903 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.ttf differ diff --git a/static/fontawesome/webfonts/fa-solid-900.woff b/static/fontawesome/webfonts/fa-solid-900.woff new file mode 100644 index 0000000..beec791 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.woff differ diff --git a/static/fontawesome/webfonts/fa-solid-900.woff2 b/static/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..978a681 Binary files /dev/null and b/static/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/static/js/jquery-1.12.4.min.js b/static/js/jquery-1.12.4.min.js new file mode 100644 index 0000000..e836475 --- /dev/null +++ b/static/js/jquery-1.12.4.min.js @@ -0,0 +1,5 @@ +/*! jQuery v1.12.4 | (c) jQuery Foundation | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=a.document,e=c.slice,f=c.concat,g=c.push,h=c.indexOf,i={},j=i.toString,k=i.hasOwnProperty,l={},m="1.12.4",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return e.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:e.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a){return n.each(this,a)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(e.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor()},push:g,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(n.isPlainObject(c)||(b=n.isArray(c)))?(b?(b=!1,f=a&&n.isArray(a)?a:[]):f=a&&n.isPlainObject(a)?a:{},g[d]=n.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray||function(a){return"array"===n.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){var b=a&&a.toString();return!n.isArray(a)&&b-parseFloat(b)+1>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==n.type(a)||a.nodeType||n.isWindow(a))return!1;try{if(a.constructor&&!k.call(a,"constructor")&&!k.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(!l.ownFirst)for(b in a)return k.call(a,b);for(b in a);return void 0===b||k.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?i[j.call(a)]||"object":typeof a},globalEval:function(b){b&&n.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b){var c,d=0;if(s(a)){for(c=a.length;c>d;d++)if(b.call(a[d],d,a[d])===!1)break}else for(d in a)if(b.call(a[d],d,a[d])===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):g.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(h)return h.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,e,g=0,h=[];if(s(a))for(d=a.length;d>g;g++)e=b(a[g],g,c),null!=e&&h.push(e);else for(g in a)e=b(a[g],g,c),null!=e&&h.push(e);return f.apply([],h)},guid:1,proxy:function(a,b){var c,d,f;return"string"==typeof b&&(f=a[b],b=a,a=f),n.isFunction(a)?(c=e.call(arguments,2),d=function(){return a.apply(b||this,c.concat(e.call(arguments)))},d.guid=a.guid=a.guid||n.guid++,d):void 0},now:function(){return+new Date},support:l}),"function"==typeof Symbol&&(n.fn[Symbol.iterator]=c[Symbol.iterator]),n.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),function(a,b){i["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=!!a&&"length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ga(),z=ga(),A=ga(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+M+"))|)"+L+"*\\]",O=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+N+")*)|.*)\\)|)",P=new RegExp(L+"+","g"),Q=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),R=new RegExp("^"+L+"*,"+L+"*"),S=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),T=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),U=new RegExp(O),V=new RegExp("^"+M+"$"),W={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M+"|[*])"),ATTR:new RegExp("^"+N),PSEUDO:new RegExp("^"+O),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},X=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Z=/^[^{]+\{\s*\[native \w/,$=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,_=/[+~]/,aa=/'|\\/g,ba=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),ca=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},da=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(ea){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fa(a,b,d,e){var f,h,j,k,l,o,r,s,w=b&&b.ownerDocument,x=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==x&&9!==x&&11!==x)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==x&&(o=$.exec(a)))if(f=o[1]){if(9===x){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(w&&(j=w.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(o[2])return H.apply(d,b.getElementsByTagName(a)),d;if((f=o[3])&&c.getElementsByClassName&&b.getElementsByClassName)return H.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==x)w=b,s=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(aa,"\\$&"):b.setAttribute("id",k=u),r=g(a),h=r.length,l=V.test(k)?"#"+k:"[id='"+k+"']";while(h--)r[h]=l+" "+qa(r[h]);s=r.join(","),w=_.test(a)&&oa(b.parentNode)||b}if(s)try{return H.apply(d,w.querySelectorAll(s)),d}catch(y){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(Q,"$1"),b,d,e)}function ga(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ha(a){return a[u]=!0,a}function ia(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ja(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function ka(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function la(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function na(a){return ha(function(b){return b=+b,ha(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function oa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=fa.support={},f=fa.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fa.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ia(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ia(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Z.test(n.getElementsByClassName),c.getById=ia(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ba,ca);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return"undefined"!=typeof b.getElementsByClassName&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=Z.test(n.querySelectorAll))&&(ia(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ia(function(a){var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Z.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ia(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",O)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Z.test(o.compareDocumentPosition),t=b||Z.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return ka(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?ka(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},fa.matches=function(a,b){return fa(a,null,null,b)},fa.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(T,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fa(b,n,null,[a]).length>0},fa.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fa.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fa.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fa.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fa.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fa.selectors={cacheLength:50,createPseudo:ha,match:W,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ba,ca),a[3]=(a[3]||a[4]||a[5]||"").replace(ba,ca),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fa.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fa.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return W.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&U.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ba,ca).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fa.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(P," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fa.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ha(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ha(function(a){var b=[],c=[],d=h(a.replace(Q,"$1"));return d[u]?ha(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ha(function(a){return function(b){return fa(a,b).length>0}}),contains:ha(function(a){return a=a.replace(ba,ca),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ha(function(a){return V.test(a||"")||fa.error("unsupported lang: "+a),a=a.replace(ba,ca).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Y.test(a.nodeName)},input:function(a){return X.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:na(function(){return[0]}),last:na(function(a,b){return[b-1]}),eq:na(function(a,b,c){return[0>c?c+b:c]}),even:na(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:na(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:na(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:na(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function ra(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j,k=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(j=b[u]||(b[u]={}),i=j[b.uniqueID]||(j[b.uniqueID]={}),(h=i[d])&&h[0]===w&&h[1]===f)return k[2]=h[2];if(i[d]=k,k[2]=a(b,c,g))return!0}}}function sa(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ta(a,b,c){for(var d=0,e=b.length;e>d;d++)fa(a,b[d],c);return c}function ua(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(c&&!c(f,d,e)||(g.push(f),j&&b.push(h)));return g}function va(a,b,c,d,e,f){return d&&!d[u]&&(d=va(d)),e&&!e[u]&&(e=va(e,f)),ha(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ta(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ua(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ua(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ua(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function wa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ra(function(a){return a===b},h,!0),l=ra(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[ra(sa(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return va(i>1&&sa(m),i>1&&qa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(Q,"$1"),c,e>i&&wa(a.slice(i,e)),f>e&&wa(a=a.slice(e)),f>e&&qa(a))}m.push(c)}return sa(m)}function xa(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=F.call(i));u=ua(u)}H.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&fa.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ha(f):f}return h=fa.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xa(e,d)),f.selector=a}return f},i=fa.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ba,ca),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=W.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ba,ca),_.test(j[0].type)&&oa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qa(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,!b||_.test(a)&&oa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ia(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ia(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ja("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ia(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ja("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ia(function(a){return null==a.getAttribute("disabled")})||ja(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fa}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.uniqueSort=n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},v=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},w=n.expr.match.needsContext,x=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,y=/^.[^:#\[\.,]*$/;function z(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(y.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return n.inArray(a,b)>-1!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;e>b;b++)if(n.contains(d[b],this))return!0}));for(b=0;e>b;b++)n.find(a,d[b],c);return c=this.pushStack(e>1?n.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(z(this,a||[],!1))},not:function(a){return this.pushStack(z(this,a||[],!0))},is:function(a){return!!z(this,"string"==typeof a&&w.test(a)?n(a):a||[],!1).length}});var A,B=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=n.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||A,"string"==typeof a){if(e="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:B.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),x.test(e[1])&&n.isPlainObject(b))for(e in b)n.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}if(f=d.getElementById(e[2]),f&&f.parentNode){if(f.id!==e[2])return A.find(a);this.length=1,this[0]=f}return this.context=d,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof c.ready?c.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};C.prototype=n.fn,A=n(d);var D=/^(?:parents|prev(?:Until|All))/,E={children:!0,contents:!0,next:!0,prev:!0};n.fn.extend({has:function(a){var b,c=n(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(n.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=w.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?n.inArray(this[0],n(a)):n.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.uniqueSort(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function F(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return u(a,"parentNode")},parentsUntil:function(a,b,c){return u(a,"parentNode",c)},next:function(a){return F(a,"nextSibling")},prev:function(a){return F(a,"previousSibling")},nextAll:function(a){return u(a,"nextSibling")},prevAll:function(a){return u(a,"previousSibling")},nextUntil:function(a,b,c){return u(a,"nextSibling",c)},prevUntil:function(a,b,c){return u(a,"previousSibling",c)},siblings:function(a){return v((a.parentNode||{}).firstChild,a)},children:function(a){return v(a.firstChild)},contents:function(a){return n.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(E[a]||(e=n.uniqueSort(e)),D.test(a)&&(e=e.reverse())),this.pushStack(e)}});var G=/\S+/g;function H(a){var b={};return n.each(a.match(G)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?H(a):n.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),h>=c&&h--}),this},has:function(a){return a?n.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=!0,c||j.disable(),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().progress(c.notify).done(c.resolve).fail(c.reject):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=e.call(arguments),d=c.length,f=1!==d||a&&n.isFunction(a.promise)?d:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(d){b[a]=this,c[a]=arguments.length>1?e.call(arguments):d,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(d>1)for(i=new Array(d),j=new Array(d),k=new Array(d);d>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().progress(h(b,j,i)).done(h(b,k,c)).fail(g.reject):--f;return f||g.resolveWith(k,c),g.promise()}});var I;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(I.resolveWith(d,[n]),n.fn.triggerHandler&&(n(d).triggerHandler("ready"),n(d).off("ready"))))}});function J(){d.addEventListener?(d.removeEventListener("DOMContentLoaded",K),a.removeEventListener("load",K)):(d.detachEvent("onreadystatechange",K),a.detachEvent("onload",K))}function K(){(d.addEventListener||"load"===a.event.type||"complete"===d.readyState)&&(J(),n.ready())}n.ready.promise=function(b){if(!I)if(I=n.Deferred(),"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll)a.setTimeout(n.ready);else if(d.addEventListener)d.addEventListener("DOMContentLoaded",K),a.addEventListener("load",K);else{d.attachEvent("onreadystatechange",K),a.attachEvent("onload",K);var c=!1;try{c=null==a.frameElement&&d.documentElement}catch(e){}c&&c.doScroll&&!function f(){if(!n.isReady){try{c.doScroll("left")}catch(b){return a.setTimeout(f,50)}J(),n.ready()}}()}return I.promise(b)},n.ready.promise();var L;for(L in n(l))break;l.ownFirst="0"===L,l.inlineBlockNeedsLayout=!1,n(function(){var a,b,c,e;c=d.getElementsByTagName("body")[0],c&&c.style&&(b=d.createElement("div"),e=d.createElement("div"),e.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(e).appendChild(b),"undefined"!=typeof b.style.zoom&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",l.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(e))}),function(){var a=d.createElement("div");l.deleteExpando=!0;try{delete a.test}catch(b){l.deleteExpando=!1}a=null}();var M=function(a){var b=n.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b},N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(O,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}n.data(a,b,c)}else c=void 0; +}return c}function Q(a){var b;for(b in a)if(("data"!==b||!n.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function R(a,b,d,e){if(M(a)){var f,g,h=n.expando,i=a.nodeType,j=i?n.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||n.guid++:h),j[k]||(j[k]=i?{}:{toJSON:n.noop}),"object"!=typeof b&&"function"!=typeof b||(e?j[k]=n.extend(j[k],b):j[k].data=n.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[n.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[n.camelCase(b)])):f=g,f}}function S(a,b,c){if(M(a)){var d,e,f=a.nodeType,g=f?n.cache:a,h=f?a[n.expando]:n.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){n.isArray(b)?b=b.concat(n.map(b,n.camelCase)):b in d?b=[b]:(b=n.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!Q(d):!n.isEmptyObject(d))return}(c||(delete g[h].data,Q(g[h])))&&(f?n.cleanData([a],!0):l.deleteExpando||g!=g.window?delete g[h]:g[h]=void 0)}}}n.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?n.cache[a[n.expando]]:a[n.expando],!!a&&!Q(a)},data:function(a,b,c){return R(a,b,c)},removeData:function(a,b){return S(a,b)},_data:function(a,b,c){return R(a,b,c,!0)},_removeData:function(a,b){return S(a,b,!0)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=n.data(f),1===f.nodeType&&!n._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));n._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){n.data(this,a)}):arguments.length>1?this.each(function(){n.data(this,a,b)}):f?P(f,a,n.data(f,a)):void 0},removeData:function(a){return this.each(function(){n.removeData(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=n._data(a,b),c&&(!d||n.isArray(c)?d=n._data(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return n._data(a,c)||n._data(a,c,{empty:n.Callbacks("once memory").add(function(){n._removeData(a,b+"queue"),n._removeData(a,c)})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},Z=/^(?:checkbox|radio)$/i,$=/<([\w:-]+)/,_=/^$|\/(?:java|ecma)script/i,aa=/^\s+/,ba="abbr|article|aside|audio|bdi|canvas|data|datalist|details|dialog|figcaption|figure|footer|header|hgroup|main|mark|meter|nav|output|picture|progress|section|summary|template|time|video";function ca(a){var b=ba.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}!function(){var a=d.createElement("div"),b=d.createDocumentFragment(),c=d.createElement("input");a.innerHTML="
a",l.leadingWhitespace=3===a.firstChild.nodeType,l.tbody=!a.getElementsByTagName("tbody").length,l.htmlSerialize=!!a.getElementsByTagName("link").length,l.html5Clone="<:nav>"!==d.createElement("nav").cloneNode(!0).outerHTML,c.type="checkbox",c.checked=!0,b.appendChild(c),l.appendChecked=c.checked,a.innerHTML="",l.noCloneChecked=!!a.cloneNode(!0).lastChild.defaultValue,b.appendChild(a),c=d.createElement("input"),c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),a.appendChild(c),l.checkClone=a.cloneNode(!0).cloneNode(!0).lastChild.checked,l.noCloneEvent=!!a.addEventListener,a[n.expando]=1,l.attributes=!a.getAttribute(n.expando)}();var da={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:l.htmlSerialize?[0,"",""]:[1,"X
","
"]};da.optgroup=da.option,da.tbody=da.tfoot=da.colgroup=da.caption=da.thead,da.th=da.td;function ea(a,b){var c,d,e=0,f="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||n.nodeName(d,b)?f.push(d):n.merge(f,ea(d,b));return void 0===b||b&&n.nodeName(a,b)?n.merge([a],f):f}function fa(a,b){for(var c,d=0;null!=(c=a[d]);d++)n._data(c,"globalEval",!b||n._data(b[d],"globalEval"))}var ga=/<|&#?\w+;/,ha=/r;r++)if(g=a[r],g||0===g)if("object"===n.type(g))n.merge(q,g.nodeType?[g]:g);else if(ga.test(g)){i=i||p.appendChild(b.createElement("div")),j=($.exec(g)||["",""])[1].toLowerCase(),m=da[j]||da._default,i.innerHTML=m[1]+n.htmlPrefilter(g)+m[2],f=m[0];while(f--)i=i.lastChild;if(!l.leadingWhitespace&&aa.test(g)&&q.push(b.createTextNode(aa.exec(g)[0])),!l.tbody){g="table"!==j||ha.test(g)?""!==m[1]||ha.test(g)?0:i:i.firstChild,f=g&&g.childNodes.length;while(f--)n.nodeName(k=g.childNodes[f],"tbody")&&!k.childNodes.length&&g.removeChild(k)}n.merge(q,i.childNodes),i.textContent="";while(i.firstChild)i.removeChild(i.firstChild);i=p.lastChild}else q.push(b.createTextNode(g));i&&p.removeChild(i),l.appendChecked||n.grep(ea(q,"input"),ia),r=0;while(g=q[r++])if(d&&n.inArray(g,d)>-1)e&&e.push(g);else if(h=n.contains(g.ownerDocument,g),i=ea(p.appendChild(g),"script"),h&&fa(i),c){f=0;while(g=i[f++])_.test(g.type||"")&&c.push(g)}return i=null,p}!function(){var b,c,e=d.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(l[b]=c in a)||(e.setAttribute(c,"t"),l[b]=e.attributes[c].expando===!1);e=null}();var ka=/^(?:input|select|textarea)$/i,la=/^key/,ma=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,na=/^(?:focusinfocus|focusoutblur)$/,oa=/^([^.]*)(?:\.(.+)|)/;function pa(){return!0}function qa(){return!1}function ra(){try{return d.activeElement}catch(a){}}function sa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)sa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=qa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return n().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=n.guid++)),a.each(function(){n.event.add(this,b,e,d,c)})}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=n.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return"undefined"==typeof n||a&&n.event.triggered===a.type?void 0:n.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(G)||[""],h=b.length;while(h--)f=oa.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=n.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=n.event.special[o]||{},l=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},i),(m=g[o])||(m=g[o]=[],m.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,l):m.push(l),n.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n.hasData(a)&&n._data(a);if(r&&(k=r.events)){b=(b||"").match(G)||[""],j=b.length;while(j--)if(h=oa.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=m.length;while(f--)g=m[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(m.splice(f,1),g.selector&&m.delegateCount--,l.remove&&l.remove.call(a,g));i&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(k)&&(delete r.handle,n._removeData(a,"events"))}},trigger:function(b,c,e,f){var g,h,i,j,l,m,o,p=[e||d],q=k.call(b,"type")?b.type:b,r=k.call(b,"namespace")?b.namespace.split("."):[];if(i=m=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!na.test(q+n.event.triggered)&&(q.indexOf(".")>-1&&(r=q.split("."),q=r.shift(),r.sort()),h=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=r.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:n.makeArray(c,[b]),l=n.event.special[q]||{},f||!l.trigger||l.trigger.apply(e,c)!==!1)){if(!f&&!l.noBubble&&!n.isWindow(e)){for(j=l.delegateType||q,na.test(j+q)||(i=i.parentNode);i;i=i.parentNode)p.push(i),m=i;m===(e.ownerDocument||d)&&p.push(m.defaultView||m.parentWindow||a)}o=0;while((i=p[o++])&&!b.isPropagationStopped())b.type=o>1?j:l.bindType||q,g=(n._data(i,"events")||{})[b.type]&&n._data(i,"handle"),g&&g.apply(i,c),g=h&&i[h],g&&g.apply&&M(i)&&(b.result=g.apply(i,c),b.result===!1&&b.preventDefault());if(b.type=q,!f&&!b.isDefaultPrevented()&&(!l._default||l._default.apply(p.pop(),c)===!1)&&M(e)&&h&&e[q]&&!n.isWindow(e)){m=e[h],m&&(e[h]=null),n.event.triggered=q;try{e[q]()}catch(s){}n.event.triggered=void 0,m&&(e[h]=m)}return b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,d,f,g,h=[],i=e.call(arguments),j=(n._data(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())a.rnamespace&&!a.rnamespace.test(g.namespace)||(a.handleObj=g,a.data=g.data,d=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==d&&(a.result=d)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&("click"!==a.type||isNaN(a.button)||a.button<1))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>-1:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]","i"),va=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,wa=/\s*$/g,Aa=ca(d),Ba=Aa.appendChild(d.createElement("div"));function Ca(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function Da(a){return a.type=(null!==n.find.attr(a,"type"))+"/"+a.type,a}function Ea(a){var b=ya.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Fa(a,b){if(1===b.nodeType&&n.hasData(a)){var c,d,e,f=n._data(a),g=n._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)n.event.add(b,c,h[c][d])}g.data&&(g.data=n.extend({},g.data))}}function Ga(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!l.noCloneEvent&&b[n.expando]){e=n._data(b);for(d in e.events)n.removeEvent(b,d,e.handle);b.removeAttribute(n.expando)}"script"===c&&b.text!==a.text?(Da(b).text=a.text,Ea(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),l.html5Clone&&a.innerHTML&&!n.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&Z.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:"input"!==c&&"textarea"!==c||(b.defaultValue=a.defaultValue)}}function Ha(a,b,c,d){b=f.apply([],b);var e,g,h,i,j,k,m=0,o=a.length,p=o-1,q=b[0],r=n.isFunction(q);if(r||o>1&&"string"==typeof q&&!l.checkClone&&xa.test(q))return a.each(function(e){var f=a.eq(e);r&&(b[0]=q.call(this,e,f.html())),Ha(f,b,c,d)});if(o&&(k=ja(b,a[0].ownerDocument,!1,a,d),e=k.firstChild,1===k.childNodes.length&&(k=e),e||d)){for(i=n.map(ea(k,"script"),Da),h=i.length;o>m;m++)g=k,m!==p&&(g=n.clone(g,!0,!0),h&&n.merge(i,ea(g,"script"))),c.call(a[m],g,m);if(h)for(j=i[i.length-1].ownerDocument,n.map(i,Ea),m=0;h>m;m++)g=i[m],_.test(g.type||"")&&!n._data(g,"globalEval")&&n.contains(j,g)&&(g.src?n._evalUrl&&n._evalUrl(g.src):n.globalEval((g.text||g.textContent||g.innerHTML||"").replace(za,"")));k=e=null}return a}function Ia(a,b,c){for(var d,e=b?n.filter(b,a):a,f=0;null!=(d=e[f]);f++)c||1!==d.nodeType||n.cleanData(ea(d)),d.parentNode&&(c&&n.contains(d.ownerDocument,d)&&fa(ea(d,"script")),d.parentNode.removeChild(d));return a}n.extend({htmlPrefilter:function(a){return a.replace(va,"<$1>")},clone:function(a,b,c){var d,e,f,g,h,i=n.contains(a.ownerDocument,a);if(l.html5Clone||n.isXMLDoc(a)||!ua.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(Ba.innerHTML=a.outerHTML,Ba.removeChild(f=Ba.firstChild)),!(l.noCloneEvent&&l.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(d=ea(f),h=ea(a),g=0;null!=(e=h[g]);++g)d[g]&&Ga(e,d[g]);if(b)if(c)for(h=h||ea(a),d=d||ea(f),g=0;null!=(e=h[g]);g++)Fa(e,d[g]);else Fa(a,f);return d=ea(f,"script"),d.length>0&&fa(d,!i&&ea(a,"script")),d=h=e=null,f},cleanData:function(a,b){for(var d,e,f,g,h=0,i=n.expando,j=n.cache,k=l.attributes,m=n.event.special;null!=(d=a[h]);h++)if((b||M(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)m[e]?n.event.remove(d,e):n.removeEvent(d,e,g.handle);j[f]&&(delete j[f],k||"undefined"==typeof d.removeAttribute?d[i]=void 0:d.removeAttribute(i),c.push(f))}}}),n.fn.extend({domManip:Ha,detach:function(a){return Ia(this,a,!0)},remove:function(a){return Ia(this,a)},text:function(a){return Y(this,function(a){return void 0===a?n.text(this):this.empty().append((this[0]&&this[0].ownerDocument||d).createTextNode(a))},null,a,arguments.length)},append:function(){return Ha(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ca(this,a);b.appendChild(a)}})},prepend:function(){return Ha(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ca(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ha(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ha(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&n.cleanData(ea(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&n.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return Y(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(ta,""):void 0;if("string"==typeof a&&!wa.test(a)&&(l.htmlSerialize||!ua.test(a))&&(l.leadingWhitespace||!aa.test(a))&&!da[($.exec(a)||["",""])[1].toLowerCase()]){a=n.htmlPrefilter(a);try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(ea(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=[];return Ha(this,arguments,function(b){var c=this.parentNode;n.inArray(this,a)<0&&(n.cleanData(ea(this)),c&&c.replaceChild(b,this))},a)}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=0,e=[],f=n(a),h=f.length-1;h>=d;d++)c=d===h?this:this.clone(!0),n(f[d])[b](c),g.apply(e,c.get());return this.pushStack(e)}});var Ja,Ka={HTML:"block",BODY:"block"};function La(a,b){var c=n(b.createElement(a)).appendTo(b.body),d=n.css(c[0],"display");return c.detach(),d}function Ma(a){var b=d,c=Ka[a];return c||(c=La(a,b),"none"!==c&&c||(Ja=(Ja||n("').appendTo(content)}wrap.show();busy=false;$.fancybox.center();currentOpts.onComplete(currentArray,currentIndex,currentOpts);_preload_images()},_preload_images=function(){var href,objNext;if((currentArray.length-1)>currentIndex){href=currentArray[currentIndex+1].href;if(typeof href!=='undefined'&&href.match(imgRegExp)){objNext=new Image();objNext.src=href}}if(currentIndex>0){href=currentArray[currentIndex-1].href;if(typeof href!=='undefined'&&href.match(imgRegExp)){objNext=new Image();objNext.src=href}}},_draw=function(pos){var dim={width:parseInt(start_pos.width+(final_pos.width-start_pos.width)*pos,10),height:parseInt(start_pos.height+(final_pos.height-start_pos.height)*pos,10),top:parseInt(start_pos.top+(final_pos.top-start_pos.top)*pos,10),left:parseInt(start_pos.left+(final_pos.left-start_pos.left)*pos,10)};if(typeof final_pos.opacity!=='undefined'){dim.opacity=pos<0.5?0.5:pos}wrap.css(dim);content.css({'width':dim.width-currentOpts.padding*2,'height':dim.height-(titleHeight*pos)-currentOpts.padding*2})},_get_viewport=function(){return[$(window).width()-(currentOpts.margin*2),$(window).height()-(currentOpts.margin*2),$(document).scrollLeft()+currentOpts.margin,$(document).scrollTop()+currentOpts.margin]},_get_zoom_to=function(){var view=_get_viewport(),to={},resize=currentOpts.autoScale,double_padding=currentOpts.padding*2,ratio;if(currentOpts.width.toString().indexOf('%')>-1){to.width=parseInt((view[0]*parseFloat(currentOpts.width))/100,10)}else{to.width=currentOpts.width+double_padding}if(currentOpts.height.toString().indexOf('%')>-1){to.height=parseInt((view[1]*parseFloat(currentOpts.height))/100,10)}else{to.height=currentOpts.height+double_padding}if(resize&&(to.width>view[0]||to.height>view[1])){if(selectedOpts.type=='image'||selectedOpts.type=='swf'){ratio=(currentOpts.width)/(currentOpts.height);if((to.width)>view[0]){to.width=view[0];to.height=parseInt(((to.width-double_padding)/ratio)+double_padding,10)}if((to.height)>view[1]){to.height=view[1];to.width=parseInt(((to.height-double_padding)*ratio)+double_padding,10)}}else{to.width=Math.min(to.width,view[0]);to.height=Math.min(to.height,view[1])}}to.top=parseInt(Math.max(view[3]-20,view[3]+((view[1]-to.height-40)*0.5)),10);to.left=parseInt(Math.max(view[2]-20,view[2]+((view[0]-to.width-40)*0.5)),10);return to},_get_obj_pos=function(obj){var pos=obj.offset();pos.top+=parseInt(obj.css('paddingTop'),10)||0;pos.left+=parseInt(obj.css('paddingLeft'),10)||0;pos.top+=parseInt(obj.css('border-top-width'),10)||0;pos.left+=parseInt(obj.css('border-left-width'),10)||0;pos.width=obj.width();pos.height=obj.height();return pos},_get_zoom_from=function(){var orig=selectedOpts.orig?$(selectedOpts.orig):false,from={},pos,view;if(orig&&orig.length){pos=_get_obj_pos(orig);from={width:pos.width+(currentOpts.padding*2),height:pos.height+(currentOpts.padding*2),top:pos.top-currentOpts.padding-20,left:pos.left-currentOpts.padding-20}}else{view=_get_viewport();from={width:currentOpts.padding*2,height:currentOpts.padding*2,top:parseInt(view[3]+view[1]*0.5,10),left:parseInt(view[2]+view[0]*0.5,10)}}return from},_animate_loading=function(){if(!loading.is(':visible')){clearInterval(loadingTimer);return}$('div',loading).css('top',(loadingFrame*-40)+'px');loadingFrame=(loadingFrame+1)%12};$.fn.fancybox=function(options){if(!$(this).length){return this}$(this).data('fancybox',$.extend({},options,($.metadata?$(this).metadata():{}))).unbind('click.fb').bind('click.fb',function(e){e.preventDefault();if(busy){return}busy=true;$(this).blur();selectedArray=[];selectedIndex=0;var rel=$(this).attr('rel')||'';if(!rel||rel==''||rel==='nofollow'){selectedArray.push(this)}else{selectedArray=$("a[rel="+rel+"], area[rel="+rel+"]");selectedIndex=selectedArray.index(this)}_start();return});return this};$.fancybox=function(obj){var opts;if(busy){return}busy=true;opts=typeof arguments[1]!=='undefined'?arguments[1]:{};selectedArray=[];selectedIndex=parseInt(opts.index,10)||0;if($.isArray(obj)){for(var i=0,j=obj.length;iselectedArray.length||selectedIndex<0){selectedIndex=0}_start()};$.fancybox.showActivity=function(){clearInterval(loadingTimer);loading.show();loadingTimer=setInterval(_animate_loading,66)};$.fancybox.hideActivity=function(){loading.hide()};$.fancybox.next=function(){return $.fancybox.pos(currentIndex+1)};$.fancybox.prev=function(){return $.fancybox.pos(currentIndex-1)};$.fancybox.pos=function(pos){if(busy){return}pos=parseInt(pos);selectedArray=currentArray;if(pos>-1&&pos1){selectedIndex=pos>=currentArray.length?0:currentArray.length-1;_start()}return};$.fancybox.cancel=function(){if(busy){return}busy=true;$('.fancybox-inline-tmp').trigger('fancybox-cancel');_abort();selectedOpts.onCancel(selectedArray,selectedIndex,selectedOpts);busy=false};$.fancybox.close=function(){if(busy||wrap.is(':hidden')){return}busy=true;if(currentOpts&&false===currentOpts.onCleanup(currentArray,currentIndex,currentOpts)){busy=false;return}_abort();$(close.add(nav_left).add(nav_right)).hide();$(content.add(overlay)).unbind();$(window).unbind("resize.fb scroll.fb");$(document).unbind('keydown.fb');content.find('iframe').attr('src',isIE6&&/^https/i.test(window.location.href||'')?'javascript:void(false)':'about:blank');if(currentOpts.titlePosition!=='inside'){title.empty()}wrap.stop();function _cleanup(){overlay.fadeOut('fast');title.empty().hide();wrap.hide();$('.fancybox-inline-tmp, select:not(#fancybox-tmp select)').trigger('fancybox-cleanup');content.empty();currentOpts.onClosed(currentArray,currentIndex,currentOpts);currentArray=selectedOpts=[];currentIndex=selectedIndex=0;currentOpts=selectedOpts={};busy=false}if(currentOpts.transitionOut=='elastic'){start_pos=_get_zoom_from();var pos=wrap.position();final_pos={top:pos.top,left:pos.left,width:wrap.width(),height:wrap.height()};if(currentOpts.opacity){final_pos.opacity=1}title.empty().hide();fx.prop=1;$(fx).animate({prop:0},{duration:currentOpts.speedOut,easing:currentOpts.easingOut,step:_draw,complete:_cleanup})}else{wrap.fadeOut(currentOpts.transitionOut=='none'?0:currentOpts.speedOut,_cleanup)}};$.fancybox.resize=function(){if(overlay.is(':visible')){overlay.css('height',$(document).height())}$.fancybox.center(true)};$.fancybox.center=function(){var view,align;if(busy){return}align=arguments[0]===true?1:0;view=_get_viewport();if(!align&&(wrap.width()>view[0]||wrap.height()>view[1])){return}wrap.stop().animate({'top':parseInt(Math.max(view[3]-20,view[3]+((view[1]-content.height()-40)*0.5)-currentOpts.padding)),'left':parseInt(Math.max(view[2]-20,view[2]+((view[0]-content.width()-40)*0.5)-currentOpts.padding))},typeof arguments[0]=='number'?arguments[0]:200)};$.fancybox.init=function(){if($("#fancybox-wrap").length){return}$('body').append(tmp=$('
'),loading=$('
'),overlay=$('
'),wrap=$('
'));outer=$('
').append('
').appendTo(wrap);outer.append(content=$('
'),close=$(''),title=$('
'),nav_left=$(''),nav_right=$(''));close.click($.fancybox.close);loading.click($.fancybox.cancel);nav_left.click(function(e){e.preventDefault();$.fancybox.prev()});nav_right.click(function(e){e.preventDefault();$.fancybox.next()});if($.fn.mousewheel){wrap.bind('mousewheel.fb',function(e,delta){if(busy){e.preventDefault()}else if($(e.target).get(0).clientHeight==0||$(e.target).get(0).scrollHeight===$(e.target).get(0).clientHeight){e.preventDefault();$.fancybox[delta>0?'prev':'next']()}})}if(!$.support.opacity){wrap.addClass('fancybox-ie')}if(isIE6){loading.addClass('fancybox-ie6');wrap.addClass('fancybox-ie6');$('').prependTo(outer)}};$.fn.fancybox.defaults={padding:10,margin:40,opacity:false,modal:false,cyclic:false,scrolling:'auto',width:560,height:340,autoScale:true,autoDimensions:true,centerOnScroll:false,ajax:{},swf:{wmode:'transparent'},hideOnOverlayClick:true,hideOnContentClick:false,overlayShow:true,overlayOpacity:0.7,overlayColor:'#777',titleShow:true,titlePosition:'float',titleFormat:null,titleFromAlt:false,transitionIn:'fade',transitionOut:'fade',speedIn:300,speedOut:300,changeSpeed:300,changeFade:'fast',easingIn:'swing',easingOut:'swing',showCloseButton:true,showNavArrows:true,enableEscapeButton:true,enableKeyboardNav:true,onStart:function(){},onCancel:function(){},onComplete:function(){},onCleanup:function(){},onClosed:function(){},onError:function(){}};$(document).ready(function(){$.fancybox.init()})})(jQuery); diff --git a/static/js/jquery.idtabs.js b/static/js/jquery.idtabs.js new file mode 100644 index 0000000..7106f54 --- /dev/null +++ b/static/js/jquery.idtabs.js @@ -0,0 +1,12 @@ +/* idTabs ~ Sean Catchpole - Version 2.2 - MIT/GPL */ +(function(){var dep={"jQuery":"http://code.jquery.com/jquery-latest.min.js"};var init=function(){(function($){$.fn.idTabs=function(){var s={};for(var i=0;i= 0; +}}); +$.extend($.fn,{ + quickfilter: function(el){ + return this.each(function(){ + var _this = $(this); + var query = _this.val().toLowerCase(); + _this.keyup(function () { + query = $(this).val().toLowerCase(); + if(query.replace(/\s/g,"") != ""){ + $(el+':exists("' + query.toString() + '")').show(); + $(el+':missing("' + query.toString() + '")').hide(); + } + else { + $(el).show(); + } + }); + }); + } +}); +})(jQuery); \ No newline at end of file diff --git a/static/js/jquery.tooltip.js b/static/js/jquery.tooltip.js new file mode 100644 index 0000000..370a39e --- /dev/null +++ b/static/js/jquery.tooltip.js @@ -0,0 +1,18 @@ +/* + + jQuery Tools 1.2.5 Tooltip - UI essentials + + NO COPYRIGHTS OR LICENSES. DO WHAT YOU LIKE. + + http://flowplayer.org/tools/tooltip/ + + Since: November 2008 + Date: Wed Sep 22 06:02:10 2010 +0000 +*/ +(function(f){function p(a,b,c){var h=c.relative?a.position().top:a.offset().top,d=c.relative?a.position().left:a.offset().left,i=c.position[0];h-=b.outerHeight()-c.offset[0];d+=a.outerWidth()+c.offset[1];if(/iPad/i.test(navigator.userAgent))h-=f(window).scrollTop();var j=b.outerHeight()+a.outerHeight();if(i=="center")h+=j/2;if(i=="bottom")h+=j;i=c.position[1];a=b.outerWidth()+a.outerWidth();if(i=="center")d-=a/2;if(i=="left")d-=a;return{top:h,left:d}}function u(a,b){var c=this,h=a.add(c),d,i=0,j= +0,m=a.attr("title"),q=a.attr("data-tooltip"),r=o[b.effect],l,s=a.is(":input"),v=s&&a.is(":checkbox, :radio, select, :button, :submit"),t=a.attr("type"),k=b.events[t]||b.events[s?v?"widget":"input":"def"];if(!r)throw'Nonexistent effect "'+b.effect+'"';k=k.split(/,\s*/);if(k.length!=2)throw"Tooltip: bad events configuration for "+t;a.bind(k[0],function(e){clearTimeout(i);if(b.predelay)j=setTimeout(function(){c.show(e)},b.predelay);else c.show(e)}).bind(k[1],function(e){clearTimeout(j);if(b.delay)i= +setTimeout(function(){c.hide(e)},b.delay);else c.hide(e)});if(m&&b.cancelDefault){a.removeAttr("title");a.data("title",m)}f.extend(c,{show:function(e){if(!d){if(q)d=f(q);else if(b.tip)d=f(b.tip).eq(0);else if(m)d=f(b.layout).addClass(b.tipClass).appendTo(document.body).hide().append(m);else{d=a.next();d.length||(d=a.parent().next())}if(!d.length)throw"Cannot find tooltip for "+a;}if(c.isShown())return c;d.stop(true,true);var g=p(a,d,b);b.tip&&d.html(a.data("title"));e=e||f.Event();e.type="onBeforeShow"; +h.trigger(e,[g]);if(e.isDefaultPrevented())return c;g=p(a,d,b);d.css({position:"absolute",top:g.top,left:g.left});l=true;r[0].call(c,function(){e.type="onShow";l="full";h.trigger(e)});g=b.events.tooltip.split(/,\s*/);if(!d.data("__set")){d.bind(g[0],function(){clearTimeout(i);clearTimeout(j)});g[1]&&!a.is("input:not(:checkbox, :radio), textarea")&&d.bind(g[1],function(n){n.relatedTarget!=a[0]&&a.trigger(k[1].split(" ")[0])});d.data("__set",true)}return c},hide:function(e){if(!d||!c.isShown())return c; +e=e||f.Event();e.type="onBeforeHide";h.trigger(e);if(!e.isDefaultPrevented()){l=false;o[b.effect][1].call(c,function(){e.type="onHide";h.trigger(e)});return c}},isShown:function(e){return e?l=="full":l},getConf:function(){return b},getTip:function(){return d},getTrigger:function(){return a}});f.each("onHide,onBeforeShow,onShow,onBeforeHide".split(","),function(e,g){f.isFunction(b[g])&&f(c).bind(g,b[g]);c[g]=function(n){n&&f(c).bind(g,n);return c}})}f.tools=f.tools||{version:"1.2.5"};f.tools.tooltip= +{conf:{effect:"toggle",fadeOutSpeed:"fast",predelay:0,delay:30,opacity:1,tip:0,position:["top","center"],offset:[0,0],relative:false,cancelDefault:true,events:{def:"mouseenter,mouseleave",input:"focus,blur",widget:"focus mouseenter,blur mouseleave",tooltip:"mouseenter,mouseleave"},layout:"
",tipClass:"tooltip"},addEffect:function(a,b,c){o[a]=[b,c]}};var o={toggle:[function(a){var b=this.getConf(),c=this.getTip();b=b.opacity;b<1&&c.css({opacity:b});c.show();a.call()},function(a){this.getTip().hide(); +a.call()}],fade:[function(a){var b=this.getConf();this.getTip().fadeTo(b.fadeInSpeed,b.opacity,a)},function(a){this.getTip().fadeOut(this.getConf().fadeOutSpeed,a)}]};f.fn.tooltip=function(a){var b=this.data("tooltip");if(b)return b;a=f.extend(true,{},f.tools.tooltip.conf,a);if(typeof a.position=="string")a.position=a.position.split(/,?\s/);this.each(function(){b=new u(f(this),a);f(this).data("tooltip",b)});return a.api?b:this}})(jQuery); diff --git a/static/js/stupidtable.min.js b/static/js/stupidtable.min.js new file mode 100644 index 0000000..1deb3aa --- /dev/null +++ b/static/js/stupidtable.min.js @@ -0,0 +1,7 @@ +(function(c){c.fn.stupidtable=function(a){return this.each(function(){var b=c(this);a=a||{};a=c.extend({},c.fn.stupidtable.default_sort_fns,a);b.data("sortFns",a);b.stupidtable_build();b.on("click.stupidtable","thead th",function(){c(this).stupidsort()});b.find("th[data-sort-onload=yes]").eq(0).stupidsort()})};c.fn.stupidtable.default_settings={should_redraw:function(a){return!0},will_manually_build_table:!1};c.fn.stupidtable.dir={ASC:"asc",DESC:"desc"};c.fn.stupidtable.default_sort_fns={"int":function(a, +b){return parseInt(a,10)-parseInt(b,10)},"float":function(a,b){return parseFloat(a)-parseFloat(b)},string:function(a,b){return a.toString().localeCompare(b.toString())},"string-ins":function(a,b){a=a.toString().toLocaleLowerCase();b=b.toString().toLocaleLowerCase();return a.localeCompare(b)}};c.fn.stupidtable_settings=function(a){return this.each(function(){var b=c(this),f=c.extend({},c.fn.stupidtable.default_settings,a);b.stupidtable.settings=f})};c.fn.stupidsort=function(a){var b=c(this),f=b.data("sort")|| +null;if(null!==f){var d=b.closest("table"),e={$th:b,$table:d,datatype:f};d.stupidtable.settings||(d.stupidtable.settings=c.extend({},c.fn.stupidtable.default_settings));e.compare_fn=d.data("sortFns")[f];e.th_index=h(e);e.sort_dir=k(a,e);b.data("sort-dir",e.sort_dir);d.trigger("beforetablesort",{column:e.th_index,direction:e.sort_dir,$th:b});d.css("display");setTimeout(function(){d.stupidtable.settings.will_manually_build_table||d.stupidtable_build();var a=l(e),a=m(a,e);if(d.stupidtable.settings.should_redraw(e)){d.children("tbody").append(a); +var a=e.$table,c=e.$th,f=c.data("sort-dir");a.find("th").data("sort-dir",null).removeClass("sorting-desc sorting-asc");c.data("sort-dir",f).addClass("sorting-"+f);d.trigger("aftertablesort",{column:e.th_index,direction:e.sort_dir,$th:b});d.css("display")}},10);return b}};c.fn.updateSortVal=function(a){var b=c(this);b.is("[data-sort-value]")&&b.attr("data-sort-value",a);b.data("sort-value",a);return b};c.fn.stupidtable_build=function(){return this.each(function(){var a=c(this),b=[];a.children("tbody").children("tr").each(function(a, +d){var e={$tr:c(d),columns:[],index:a};c(d).children("td").each(function(a,b){var d=c(b).data("sort-value");"undefined"===typeof d&&(d=c(b).text(),c(b).data("sort-value",d));e.columns.push(d)});b.push(e)});a.data("stupidsort_internaltable",b)})};var l=function(a){var b=a.$table.data("stupidsort_internaltable"),f=a.th_index,d=a.$th.data("sort-multicolumn"),d=d?d.split(","):[],e=c.map(d,function(b,d){var c=a.$table.find("th"),e=parseInt(b,10),f;e||0===e?f=c.eq(e):(f=c.siblings("#"+b),e=c.index(f)); +return{index:e,$e:f}});b.sort(function(b,c){for(var d=e.slice(0),g=a.compare_fn(b.columns[f],c.columns[f]);0===g&&d.length;){var g=d[0],h=g.$e.data("sort"),g=(0,a.$table.data("sortFns")[h])(b.columns[g.index],c.columns[g.index]);d.shift()}return 0===g?b.index-c.index:g});a.sort_dir!=c.fn.stupidtable.dir.ASC&&b.reverse();return b},m=function(a,b){var f=c.map(a,function(a,c){return[[a.columns[b.th_index],a.$tr,c]]});b.column=f;return c.map(a,function(a){return a.$tr})},k=function(a,b){var f,d=b.$th, +e=c.fn.stupidtable.dir;a?f=a:(f=a||d.data("sort-default")||e.ASC,d.data("sort-dir")&&(f=d.data("sort-dir")===e.ASC?e.DESC:e.ASC));return f},h=function(a){var b=0,f=a.$th.index();a.$th.parents("tr").find("th").slice(0,f).each(function(){var a=c(this).attr("colspan")||1;b+=parseInt(a,10)});return b}})(jQuery); diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..a0ef77a Binary files /dev/null and b/static/logo.png differ