diff --git a/Dockerfile b/Dockerfile index a3339bb..44b4814 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,15 @@ RUN apt-get update \ libreoffice firefox \ fonts-wqy-microhei \ language-pack-zh-hant language-pack-gnome-zh-hant firefox-locale-zh-hant libreoffice-l10n-zh-tw \ + nginx \ + python-pip \ && apt-get autoclean \ && apt-get autoremove \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* ADD noVNC /noVNC/ +ADD nginx.conf /etc/nginx/sites-enabled/default ADD startup.sh / ADD supervisord.conf /etc/ EXPOSE 6080 diff --git a/noVNC/index.html b/noVNC/index.html index a5a841a..8efdfb1 100644 --- a/noVNC/index.html +++ b/noVNC/index.html @@ -7,7 +7,7 @@ var port = window.location.port; if (!port) port = window.location.protocol[4] == 's' ? 443 : 80; - window.location.href = "vnc.html?autoconnect=1&host=127.0.0.1&port=" + port; + window.location.href = "vnc_auto.html?autoconnect=1&host=127.0.0.1&port=" + port; Page Redirection diff --git a/supervisord.conf b/supervisord.conf index ce7aac6..3ce76d6 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -34,7 +34,7 @@ redirect_stderr=true [program:x11vnc] priority=20 directory=/ -command=/bin/dash -c "while :; do x11vnc -display :1 -xkb; done" +command=x11vnc -display :1 -xkb -forever user=root autostart=true autorestart=true diff --git a/web/auth/__init__.py b/web/auth/__init__.py new file mode 100644 index 0000000..9d439c8 --- /dev/null +++ b/web/auth/__init__.py @@ -0,0 +1,119 @@ +__all__ = ['auth'] +__version__ = '0.1' + + +from flask_login import (LoginManager, + login_required, + login_user, + current_user, + login_fresh, + logout_user, + AnonymousUserMixin, + ) +from functools import wraps +from db.sql import User as DbUser +import sha + + +def noauth(func): + @wraps(func) + def with_logging(*args, **kwargs): + return func(*args, **kwargs) + return with_logging + + +class Auth(object): + def init_app(self, app, method): + self._login_manager = LoginManager() + self._login_manager.session_protection = "basic" + self._login_manager.init_app(app) + self._login_manager.user_loader(self._load_user) + self._login_manager.anonymous_user = Anonymous + self.login_required = login_required + self.login_fresh = login_fresh + self.current_user = current_user + + def login(self, **kargs): + if 'username' in kargs and 'password' in kargs: + u = User.get(kargs['username'], kargs['password']) + if u is None: + return None + login_user(u, remember=kargs['remember']) + return u + return None + + def unauthorized_handler(self, func): + self._login_manager.unauthorized_handler(func) + + @staticmethod + def _load_user(userid): + u = User.get(userid, None) + return u + + def logout(self, username): + User.delete(username) + return logout_user() + + +class User(object): + _users = {} + + def __init__(self, userid): + self._userid = userid + self._is_admin = False + + @classmethod + def get(cls, u, password): + if password is None: + if u in cls._users: + return cls._users[u] + return None + if not cls.authenticate(u, password): + return None + user = User(u) + if u == 'admin': + user._is_admin = True + cls._users[u] = user + return user + + @classmethod + def authenticate(cls, u, password): + users = DbUser.select().where(DbUser.user == u) + if users.count() <= 0: + return False + if users[0].password == sha.new(password).hexdigest(): + return True + return False + + @classmethod + def delete(cls, u): + if u in cls._users: + del cls._users[u] + + def is_authenticated(self): + return True + + def is_active(self): + return True + + def is_anonymous(self): + return False + + def is_admin(self): + return self._is_admin + + def get_id(self): + return self._userid + + def username(self): + return self._userid + + +class Anonymous(AnonymousUserMixin): + def __init__(self): + self._userid = 'Anonymous' + + def username(self): + return self._userid + +auth = Auth() diff --git a/web/config/__init__.py b/web/config/__init__.py new file mode 100644 index 0000000..e948978 --- /dev/null +++ b/web/config/__init__.py @@ -0,0 +1,15 @@ +class Default(object): + DEBUG = True + + +class Development(Default): + PHASE = 'development' + + +class Staging(Default): + PHASE = 'staging' + + +class Production(Default): + PHASE = 'production' + DEBUG = False diff --git a/web/db/__init__.py b/web/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/db/sql.py b/web/db/sql.py new file mode 100644 index 0000000..c64eb82 --- /dev/null +++ b/web/db/sql.py @@ -0,0 +1,96 @@ +import json +import logging +from peewee import (SqliteDatabase, Model, + CharField + ) +from os.path import exists as pexists +import datetime +import sha + + +DATABASE = 'lightop.sqlite' +DB_USER_VERSION = 1 +database = SqliteDatabase(DATABASE, threadlocals=True) + + +class BaseModel(Model): + class Meta: + database = database + + def __str__(self): + r = {} + for k in self._data.keys(): + try: + r[k] = str(getattr(self, k)) + except: + r[k] = json.dumps(getattr(self, k)) + return str(r) + + def serialize(self): + r = {} + for k in self._data.keys(): + try: + # value = getattr(self, k) + # if isinstance(value, int): + # r[k] = va + r[k] = getattr(self, k) + if isinstance(r[k], datetime.datetime): + r[k] = int((r[k] - datetime.datetime(1970, 1, 1)) + .total_seconds()) + except: + r[k] = json.dumps(getattr(self, k)) + return r + + def marshal(self): + return self + + +class KeyValue(BaseModel): + key = CharField() + value = CharField() + + class Meta: + order_by = ('key',) + + +class User(BaseModel): + user = CharField(default='') + password = CharField(default='') + + +def create_tables(): + database.connect() + set_user_verion() + database.create_tables([KeyValue, User]) + User.create(user='admin', password=sha.new('admin').hexdigest()) + + +def connect(): + database.connect() + + +def close(): + database.close() + + +def set_user_verion(): + version = 'PRAGMA user_version = ' + str(DB_USER_VERSION) + database.execute_sql(version) + + +def get_user_version(): + version = 'PRAGMA user_version' + cursor = database.execute_sql(version) + v = cursor.fetchone() + logging.info('Existing database user version: ' + str(v[0])) + return v[0] + + +if not pexists(DATABASE): + create_tables() +else: + v = get_user_version() + if v < DB_USER_VERSION: + logging.warn('Existing database version is outdated') + elif v > DB_USER_VERSION: + logging.warn("DB version doesn't match") diff --git a/web/lightop/__init__.py b/web/lightop/__init__.py new file mode 100644 index 0000000..73440f1 --- /dev/null +++ b/web/lightop/__init__.py @@ -0,0 +1,540 @@ +from flask import (Flask, + request, + render_template, + abort, + Response, + redirect, + ) +import os + + +# Flask app +app = Flask(__name__, + static_folder='static', static_url_path='', + instance_relative_config=True) +CONFIG = os.environ.get('CONFIG') or 'config.Development' +app.config.from_object('config.Default') +app.config.from_object(CONFIG) +app.config.from_pyfile('application.cfg') + +# logging +import logging +from log.config import LoggingConfiguration +LoggingConfiguration.set( + logging.DEBUG if os.getenv('DEBUG') else logging.INFO, + 'lightop.log', name='Web') + +from auth import auth +auth.init_app(app, app.config['PHASE']) + + +from gevent import spawn, sleep +from geventwebsocket import WebSocketError +import requests +import websocket +import docker +import json +from functools import wraps +import subprocess +import datetime +import sha +import re + +from db.sql import User as DbUser + + +CHUNK_SIZE = 1024 +CID2IMAGE = {'ubuntu-trusty-ttyjs': 'dorowu/lightop-ubuntu-trusty-ttyjs', + 'ubuntu-trusty-lxde': 'dorowu/lightop-ubuntu-trusty-lxde'} +RE_OWNER_CNAME = re.compile('^/(.*)_({})$'.format('|'.join(CID2IMAGE.keys()))) + + +def exception_to_json(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + result = func(*args, **kwargs) + return result + except (BadRequest, + KeyError, + ValueError, + ) as e: + result = {'error': {'code': 400, + 'message': str(e)}} + except PermissionDenied as e: + result = {'error': {'code': 403, + 'message': ', '.join(e.args)}} + except (NotImplementedError, RuntimeError, AttributeError) as e: + result = {'error': {'code': 500, + 'message': ', '.join(e.args)}} + return json.dumps(result) + return wrapper + + +class PermissionDenied(Exception): + pass + + +class BadRequest(Exception): + pass + + +@app.route('/') +def index(): + return redirect("index.html") + + +@app.route('/') +@auth.login_required +def root(url): + logging.info("Root route, path: %s", url) + # If referred from a proxy request, then redirect to a URL with the proxy prefix. + # This allows server-relative and protocol-relative URLs to work. + proxy_ref = proxy_ref_info(request) + if proxy_ref: + redirect_url = "/p/%s/%s%s" % (proxy_ref[0], url, ("?" + request.query_string if request.query_string else "")) + logging.info("Redirecting referred URL to: %s", redirect_url) + return redirect(redirect_url) + # Otherwise, default behavior + return render_template('hello.html', name=url, request=request) + + +@app.route('/u//') +@auth.login_required +def proxy_user_root(cid): + return proxy_user(cid, '') + + +def container_create_and_network(cid): + user = auth.current_user.username() + cname = user + '_' + cid + dc = docker.Client() + # create container + for c in dc.containers(all=True): + #logging.info(str(c['Names'])) + if '/' + cname in c['Names']: + break + else: + if cid not in CID2IMAGE: + raise BadRequest(cid, 'not exist') + try: + os.makedirs('mnt/home/' + user) + except OSError: + pass + try: + os.makedirs('mnt/public') + except OSError: + pass + env = ['USER=' + user, 'PASS=' + user] + if 'width' in request.args: + env.append('WIDTH=' + str(request.args['width'])) + if 'height' in request.args: + env.append('HEIGHT=' + str(request.args['height'])) + logging.info('create container') + dc.create_container(CID2IMAGE[cid], name=cname, + volumes=['/home/' + user, '/mnt/public'], + environment=env) + cinfo = dc.inspect_container(user + '_' + cid) + # start container + logging.info(cinfo['State']['Running']) + if not cinfo['State']['Running']: + binds = {} + binds[os.path.join(os.getcwd(), 'mnt', 'home', user)] = {'bind': '/home/' + user, 'ro': False} + binds[os.path.join(os.getcwd(), 'mnt', 'public')] = {'bind': '/mnt/public', 'ro': False} + logging.info('start container') + dc.start(cname, binds=binds) + cinfo = dc.inspect_container(user + '_' + cid) + # get ip and port + ipaddr = cinfo['NetworkSettings']['IPAddress'] + for p in cinfo['NetworkSettings']['Ports'].keys(): + port, proto = p.split('/') + if port != '22': + port = int(port) + break + else: + raise RuntimeError('port', cinfo['NetworkSettings']['Ports']) + + start = datetime.datetime.now() + while True: + now = datetime.datetime.now() + if (now - start).total_seconds() > 10: + logging.error('probe failed') + raise RuntimeError('probe failed') + try: + r = requests.get('http://{}:{}/'.format(ipaddr, port)) + if 200 <= r.status_code < 400: + break + except Exception: + pass + sleep(1) + return ipaddr, port + + +# hijact LXDE VNC +@app.route('/u/ubuntu-trusty-lxde/') +@auth.login_required +def proxy_user_vnc(path): + logging.info('vnc ' + path) + # auth pass + if path.endswith('/websockify'): + logging.info('vnc done') + return '' + if path != 'vnc_auto.html': + return proxy_user('ubuntu-trusty-lxde', path) + if len(request.args.get('hijact', '')) >= 1: + return proxy_user('ubuntu-trusty-lxde', path) + ipaddr, port = container_create_and_network('ubuntu-trusty-lxde') + user = auth.current_user.username() + #TODO remove when user deleted + subprocess.check_call(r"sed -i '/^location .*ubuntu-trusty-lxde\/{user}\//,/}}/d' nginx/ws-login.conf".format(user=user), + shell=True) + with open('nginx/ws-login.conf', 'a+') as f: + f.write('\nlocation /u/ubuntu-trusty-lxde/{user}/websockify\n' + '{{\n' + ' auth_request /login_refresh_code;\n' + ' proxy_pass http://{ipaddr}:{port}/websockify;\n' + ' proxy_redirect off;\n' + ' proxy_buffering off;\n' + ' proxy_set_header Host $host;\n' + ' proxy_set_header X-Real-IP $remote_addr;\n' + ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n' + ' proxy_http_version 1.1;\n' + ' proxy_set_header Upgrade $http_upgrade;\n' + ' proxy_set_header Connection "Upgrade";\n' + '}}'.format(user=user, ipaddr=ipaddr, port=port) + ) + subprocess.check_call('sudo nginx -c ' + os.getcwd() + + '/nginx.conf -s reload', shell=True) + + geometry = '' + geometry += '&width=' + request.args.get('width', '1024') + geometry += '&height=' + request.args.get('height', '768') + return redirect('/u/ubuntu-trusty-lxde/' + + 'vnc.html' + + ('?host={host}&port={port}&path={path}' + '&hijact=1&autoconnect=1{geometry}').format( + host=re.findall('https?://([^/:]+)([:0-9]*)/', request.url_root)[0][0], + port=6051, + path='u/ubuntu-trusty-lxde/' + user + '/websockify', + geometry=geometry) + ) + + +@app.route('/u//') +@auth.login_required +def proxy_user(cid, path): + try: + ipaddr, port = container_create_and_network(cid) + except docker.errors.APIError as e: + return json.dumps({'status': 400, 'message': str(e)}) + except RuntimeError as e: + return json.dumps({'status': 500, 'message': str(e)}) + + # websocket + if request.environ.get('wsgi.websocket'): + return proxy_user_websocket(ipaddr, port, path) + + # page + url = '%s:%d' % (ipaddr, port) + if len(path) > 0: + url += '/' + path + r = get_source_rsp(url) + logging.info("Got %s response from %s", r.status_code, url) + headers = dict(r.headers) + + def generate(): + for chunk in r.iter_content(CHUNK_SIZE): + yield chunk + return Response(generate(), headers=headers) + + +@app.route("/session", methods=["GET"]) +@exception_to_json +def sessions(): + return json.dumps(CID2IMAGE.keys()) + + +@app.route("/user/", methods=["GET"]) +@exception_to_json +@auth.login_required +def users(): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + result = [] + for u in DbUser.select(): + result.append({'name': u.user, + 'id': u.id, + 'volume': ['/mnt/public', '/home/' + u.user]}) + return json.dumps(result) + + +@app.route("/user/", methods=["POST"]) +@exception_to_json +@auth.login_required +def user_create(): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + try: + username = request.form['username'] + password = request.form['password'] + except KeyError: + raise BadRequest('username or password') + u = DbUser.create(user=username, password=sha.new(password).hexdigest()) + return user_detail(u.id) + + +@app.route("/user/", methods=["GET"]) +@exception_to_json +@auth.login_required +def user_detail(uid): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + try: + u = DbUser.get(DbUser.id == uid) + except: + return '{}' + return json.dumps({'name': u.user, + 'id': u.id, + 'volume': ['/mnt/public', '/home/' + u.user]}) + + +@app.route("/user/", methods=["DELETE"]) +@exception_to_json +@auth.login_required +def user_delete(uid): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + try: + u = DbUser.get(DbUser.id == uid) + except: + return json.dumps({'num': 0}) + return json.dumps({'num': u.delete_instance()}) + + +@app.route("/login", methods=["POST", "PUT"]) +@exception_to_json +def login(): + """Login + """ + try: + kargs = dict() + kargs['username'] = request.form['username'] + kargs['password'] = request.form['password'] + kargs['remember'] = request.form['remember'] \ + if 'remember' in request.form else False + except KeyError: + raise BadRequest('username, password or sid') + user = auth.login(**kargs) + if user is not None: + if user.is_anonymous(): + return json.dumps({'username': 'nobody', + 'isAdmin': False, + 'anonymous': True}) + return json.dumps({'username': user.username(), + 'isAdmin': user.is_admin()}) + raise PermissionDenied('Wrong user name or password') + + +@app.route("/container/", methods=["GET"]) +@exception_to_json +@auth.login_required +def containers(): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + result = [] + dc = docker.Client() + for c in dc.containers(): + r = RE_OWNER_CNAME.match(c['Names'][0]) + if r is None: + continue + result.append({'id': c['Id'], + 'session': r.group(2), + 'owner': r.group(1)}) + return json.dumps(result) + + +@app.route("/container/", methods=["DELETE"]) +@exception_to_json +@auth.login_required +def container_delete(cid): + user = auth.current_user.username() + if user != 'admin': + raise PermissionDenied('admin only') + try: + dc = docker.Client() + logging.info(cid) + c = dc.inspect_container(cid) + r = RE_OWNER_CNAME.match(c['Name']) + if r is None: + raise RuntimeError() + dc.kill(cid) + dc.remove_container(cid) + except Exception as e: + logging.error(str(e)) + return json.dumps({'num': 0}) + return json.dumps({'num': 1}) + + +@app.route("/login_refresh", methods=["GET"]) +@exception_to_json +@auth.login_required +def login_refresh(): + """Refresh token + """ + user = auth.current_user + #raise PermissionDenied('Not a valid user') + return json.dumps({'username': user.username(), 'isAdmin': False}) + + +@app.route("/login_refresh_code", methods=["GET"]) +@exception_to_json +@auth.login_required +def login_refresh_code(): + """Refresh token + """ + logging.info('!!!!!!!!!!!!!!!!! refresh code') + user = auth.current_user + #raise PermissionDenied('Not a valid user') + return json.dumps({'username': user.username(), 'isAdmin': False}) + + +@app.route("/logout", methods=["PUT"]) +@exception_to_json +@auth.login_required +def logout(): + """Logout + """ + try: + username = auth.current_user.username() + except KeyError: + return json.dumps({'error': {'code': 400}}) + if auth.logout(username): + return json.dumps({'username': username}) + return json.dumps({'error': {'code': 403}}) + + +def proxy_user_websocket(ipaddr, port, path): + def c2s(client, server): + while True: + inp = client.receive() + if inp is None: + raise WebSocketError() + server.send(inp) + + def get_headers(): + headers = [] + #for header in request.environ: + # if not header.startswith('HTTP_'): + # continue + # if not header.startswith('HTTP_SEC_') \ + # and not header.startswith('HTTP_ACCEPT_') \ + # and not header.startswith('HTTP_USER_AGENT'): + # continue + # upper = True + # k = '' + # for c in header[5:].replace('_', '-').lower(): + # if upper: + # k += c.upper() + # upper = False + # else: + # k += c + # if c == '-': + # upper = True + # headers.append('%s: %s' % (k, request.environ[header])) + return headers + + #https://stackoverflow.com/questions/18240358/html5-websocket-connecting-to-python + client = request.environ['wsgi.websocket'] + url = '%s:%d' % (ipaddr, port) + if len(path) > 0: + url += '/' + path + logging.info('websocket: ' + url) + headers = [] + #headers = get_headers() + #logging.info('headers: ' + str(headers)) + server = websocket.create_connection("ws://" + url, header=headers) + try: + spawn(c2s, client, server) + while True: + inp = server.recv() + if inp is None: + raise WebSocketError() + client.send(inp) + except WebSocketError as e: + logging.error(e) + except client.WebSocketConnectionClosedException: + pass + return json.dumps({'status': 200}) + + +def get_source_rsp(url): + url = 'http://%s' % url + logging.info("Fetching %s", url) + # Ensure the URL is approved, else abort + if not is_approved(url): + logging.warn("URL is not approved: %s", url) + abort(403) + # Pass original Referer for subsequent resource requests + proxy_ref = proxy_ref_info(request) + headers = {"Referer": "http://%s/%s" % (proxy_ref[0], proxy_ref[1])} if proxy_ref else {} + # Fetch the URL, and stream it back + logging.info("Fetching with headers: %s, %s", url, headers) + return requests.get(url, stream=True, params=request.args, headers=headers) + + +def is_approved(url): + return True + + +def split_url(url): + """Splits the given URL into a tuple of (protocol, host, uri)""" + proto, rest = url.split(':', 1) + rest = rest[2:].split('/', 1) + host, uri = (rest[0], rest[1]) if len(rest) == 2 else (rest[0], "") + return (proto, host, uri) + + +def proxy_ref_info(request): + """Parses out Referer info indicating the request is from a previously proxied page. + + For example, if: + Referer: http://localhost:8080/p/google.com/search?q=foo + then the result is: + ("google.com", "search?q=foo") + """ + ref = request.headers.get('referer') + if ref: + _, _, uri = split_url(ref) + if uri.find("/") < 0: + return None + first, rest = uri.split("/", 1) + if first in "pd": + parts = rest.split("/", 1) + r = (parts[0], parts[1]) if len(parts) == 2 else (parts[0], "") + logging.info("Referred by proxy host, uri: %s, %s", r[0], r[1]) + return r + return None + + +for image in CID2IMAGE.values(): + image = image.split(':')[0] + cmd = 'docker images | grep -q "^{0} " || docker pull {0}'.format(image) + logging.info(cmd) + subprocess.check_call(cmd, shell=True) + + +try: + os.makedirs('nginx') +except: + pass +with open('nginx/ws-login.conf', 'w+') as f: + f.truncate() + + +if __name__ == '__main__': + app.run(host=app.config['ADDRESS'], port=app.config['PORT']) diff --git a/web/log/__init__.py b/web/log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/log/config.py b/web/log/config.py new file mode 100644 index 0000000..dff7ea5 --- /dev/null +++ b/web/log/config.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +import sys +import logging +import logging.handlers + + +#The terminal has 8 colors with codes from 0 to 7 +BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) + +#These are the sequences need to get colored ouput +RESET_SEQ = "\033[0m" +COLOR_SEQ = "\033[1;%dm" +BOLD_SEQ = "\033[1m" + +#The background is set with 40 plus the number of the color, +#and the foreground with 30 +COLORS = { + 'WARNING': COLOR_SEQ % (30 + YELLOW) + 'WARN ' + RESET_SEQ, + 'INFO': COLOR_SEQ % (30 + WHITE) + 'INFO ' + RESET_SEQ, + 'DEBUG': COLOR_SEQ % (30 + BLUE) + 'DEBUG' + RESET_SEQ, + 'CRITICAL': COLOR_SEQ % (30 + YELLOW) + 'CRITI' + RESET_SEQ, + 'ERROR': COLOR_SEQ % (30 + RED) + 'ERROR' + RESET_SEQ, +} + + +class ColoredFormatter(logging.Formatter): + def __init__(self, msg, use_color=True): + logging.Formatter.__init__(self, msg) + self.use_color = use_color + + def format(self, record): + if self.use_color: + record.levelname = COLORS.get(record.levelname, record.levelname) + return logging.Formatter.format(self, record) + + +class LoggingConfiguration(object): + COLOR_FORMAT = "[%(asctime)s" + \ + "][%(threadName)-22s][%(levelname)s] %(message)s " + \ + "(" + BOLD_SEQ + "%(filename)s" + RESET_SEQ + ":%(lineno)d)" + NO_COLOR_FORMAT = "[%(asctime)s][%(threadName)-22s][%(levelname)s] " + \ + "%(message)s " + \ + "(%(filename)s:%(lineno)d)" + FILE_FORMAT = "[%(asctime)s][%(threadName)-22s][%(levelname)s] " + \ + "%(message)s " + + @classmethod + def set(cls, log_level, log_filename, append=None, **kwargs): + """ Configure a rotating file logging + """ + logger = logging.getLogger() + logger.setLevel(log_level) + + COLOR_FORMAT = cls.COLOR_FORMAT + NO_COLOR_FORMAT = cls.NO_COLOR_FORMAT + FILE_FORMAT = cls.FILE_FORMAT + if 'name' in kwargs: + COLOR_FORMAT = COLOR_FORMAT.replace('%(threadName)-22s', + '%-22s' % (kwargs['name'])) + NO_COLOR_FORMAT = NO_COLOR_FORMAT.replace( + '%(threadName)-22s', '%-22s' % (kwargs['name'])) + FILE_FORMAT = FILE_FORMAT.replace( + '%(threadName)-22s', '%s' % (kwargs['name'])) + + # Log to rotating file + try: + fh = logging.handlers.RotatingFileHandler(log_filename, + mode='a+', + backupCount=3) + fh = logging.FileHandler(log_filename, mode='a+') + fh.setFormatter(ColoredFormatter(FILE_FORMAT, False)) + fh.setLevel(log_level) + logger.addHandler(fh) + if not append: + # Create a new log file on every new + fh.doRollover() + except: + pass + + # Log to sys.stderr using log level passed through command line + if log_level != logging.NOTSET: + log_handler = logging.StreamHandler(sys.stdout) + if sys.platform.find('linux') >= 0: + formatter = ColoredFormatter(COLOR_FORMAT) + else: + formatter = ColoredFormatter(NO_COLOR_FORMAT, False) + log_handler.setFormatter(formatter) + log_handler.setLevel(log_level) + logger.addHandler(log_handler) diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..d9861e0 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,40 @@ + +# 4 worker processes is usually sufficient for a webserver serving +# both static files and passing dynamic requests back to apache, fastcgi or an app server +worker_processes 4; + +# normally you leave this at the default of 1024 +events { + worker_connections 1024; +} + + +http { + server { + listen 6051; + server_name localhost; + access_log off; + error_log /tmp/lightop-nginx-error.log; + access_log /tmp/lightop-nginx-access.log; + + location / + { + proxy_pass http://127.0.0.1:6050; + proxy_redirect off; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /login_refresh_code + { + proxy_pass http://127.0.0.1:6050; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } + + include nginx/ws-login.conf; + } +} diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..ec23674 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,17 @@ +Flask==0.10.1 +Flask-Login==0.2.11 +Jinja2==2.7.3 +MarkupSafe==0.23 +Werkzeug==0.9.6 +argparse==1.2.1 +backports.ssl-match-hostname==3.4.0.2 +docker-py==0.5.3 +gevent==1.0.1 +gevent-websocket==0.9.3 +greenlet==0.4.5 +itsdangerous==0.24 +peewee==2.4.1 +requests==2.4.3 +six==1.8.0 +websocket-client==0.21.0 +wsgiref==0.1.2 diff --git a/web/run.py b/web/run.py new file mode 100755 index 0000000..ae5656a --- /dev/null +++ b/web/run.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python + +import os +import time +import sys +import subprocess +import signal + + +def run_with_reloader(main_func, extra_files=None, interval=1): + """Run the given function in an independent python interpreter.""" + def find_files(directory="./"): + for root, dirs, files in os.walk(directory): + for basename in files: + if basename.endswith('.py'): + filename = os.path.join(root, basename) + yield filename + + if os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + try: + os.setpgid(0, 0) + main_func() + except KeyboardInterrupt: + pass + return + + procs = None + try: + while True: + print('* Restarting with reloader ' + str(sys.executable)) + args = [sys.executable] + sys.argv + new_environ = os.environ.copy() + new_environ['WERKZEUG_RUN_MAIN'] = 'true' + + procs = subprocess.Popen(args, env=new_environ) + mtimes = {} + restart = False + while not restart: + for filename in find_files(): + try: + mtime = os.stat(filename).st_mtime + except OSError: + continue + + old_time = mtimes.get(filename) + if old_time is None: + mtimes[filename] = mtime + continue + elif mtime > old_time: + print('* Detected change in %r, reloading' % filename) + restart = True + break + time.sleep(interval) + + killpg(procs.pid, signal.SIGTERM) + except KeyboardInterrupt: + pass + finally: + killpg(procs.pid, signal.SIGTERM) + + +def killpg(pgid, send_signal=signal.SIGKILL): + print('kill PGID {}'.format(pgid)) + try: + os.killpg(pgid, send_signal) + #os.killpg(pgid, signal.SIGKILL) + except: + pass + + +def create_instance_config(): + if not os.path.exists('instance'): + os.makedirs('instance') + + with open(os.path.join('instance', 'application.cfg'), 'wb+') as f: + f.write('SECRET_KEY = \'') + f.write("".join("\\x{:02x}".format(ord(c)) for c in os.urandom(24))) + f.write('\'\n') + f.write('VERSION = \'') + if os.path.exists('version'): + with open('version') as fv: + version = fv.read().strip() + f.write(version) + else: + f.write('unknown') + f.write('\'\n') + if '--debug' not in sys.argv: + os.chmod(os.path.join('instance', 'application.cfg'), 0600) + + +def main(): + create_instance_config() + + def run_server(): + from gevent import monkey + monkey.patch_all(subprocess=True) + from gevent.wsgi import WSGIServer + import socket + from geventwebsocket.handler import WebSocketHandler + + os.environ['CONFIG'] = CONFIG + from lightop import app + + if not DEBUG: # run on NAS + from werkzeug import SharedDataMiddleware + app.wsgi_app = SharedDataMiddleware(app.wsgi_app, { + '/': os.path.join(os.path.dirname(__file__), 'static') + }) + # websocket conflict: WebSocketHandler + if DEBUG or STAGING: + # from werkzeug.debug import DebuggedApplication + app.debug = True + # app = DebuggedApplication(app, evalex=True) + + print('Fork monitor programs') + pgid = os.getpgid(0) + procs = [] + procs.extend([subprocess.Popen(program, close_fds=True, shell=True) + for program in PROGRAMS]) + signal.signal(signal.SIGTERM, lambda *args: killpg(pgid)) + signal.signal(signal.SIGHUP, lambda *args: killpg(pgid)) + signal.signal(signal.SIGINT, lambda *args: killpg(pgid)) + + print('Running on port ' + str(PORT)) + try: + http_server = WSGIServer(('', PORT), app, + handler_class=WebSocketHandler) + http_server.serve_forever() + except socket.error as e: + print(e) + + DEBUG = True if '--debug' in sys.argv else False + STAGING = True if '--staging' in sys.argv else False + CONFIG = 'config.Development' if DEBUG else 'config.Production' + CONFIG = 'config.Staging' if STAGING else CONFIG + PORT = 6050 + PROGRAMS = (('sudo nginx -c ${PWD}/nginx.conf'),) + #PROGRAMS = ('python lxc-monitor.py', + # 'python docker-monitor.py') + signal.signal(signal.SIGCHLD, signal.SIG_IGN) + + if DEBUG or STAGING: + main = lambda: run_with_reloader(run_server) + else: + main = run_server + main() + + +if __name__ == "__main__": + main() diff --git a/web/static/6df2b309.favicon.ico b/web/static/6df2b309.favicon.ico new file mode 100644 index 0000000..6527905 Binary files /dev/null and b/web/static/6df2b309.favicon.ico differ diff --git a/web/static/fonts/glyphicons-halflings-regular.eot b/web/static/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..4a4ca86 Binary files /dev/null and b/web/static/fonts/glyphicons-halflings-regular.eot differ diff --git a/web/static/fonts/glyphicons-halflings-regular.svg b/web/static/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..25691af --- /dev/null +++ b/web/static/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/static/fonts/glyphicons-halflings-regular.ttf b/web/static/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..67fa00b Binary files /dev/null and b/web/static/fonts/glyphicons-halflings-regular.ttf differ diff --git a/web/static/fonts/glyphicons-halflings-regular.woff b/web/static/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..8c54182 Binary files /dev/null and b/web/static/fonts/glyphicons-halflings-regular.woff differ diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..226b71d --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,17 @@ + Docker Lightop
# User Volume Action
Container Session Owner Action
\ No newline at end of file diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 0000000..ee2cc21 --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# robotstxt.org/ + +User-agent: * diff --git a/web/static/scripts/046bbd30.main.js b/web/static/scripts/046bbd30.main.js new file mode 100644 index 0000000..6f8c70a --- /dev/null +++ b/web/static/scripts/046bbd30.main.js @@ -0,0 +1 @@ +var api={};api.login=function(a,b,c,d){$.ajax({url:"/login",type:"POST",data:{username:a,password:b}}).done(function(a){json=JSON.parse(a),d({session:c,user:json,error:json.error})}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.session_list=function(a){$.ajax({url:"/session",type:"GET"}).done(function(b){var c=[];json=JSON.parse(b),$.each(json,function(a,b){c.push({name:b,value:b})}),a(c)}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.user_list=function(a){$.ajax({url:"/user/",type:"GET"}).done(function(b){var c=[];json=JSON.parse(b),$.each(json,function(a,b){c.push({id:b.id,name:b.name,volume:b.volume})}),a(c)}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.user_add=function(a,b,c){$.ajax({url:"/user/",type:"POST",data:{username:a,password:b}}).done(function(a){json=JSON.parse(a),c("add")}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.user_delete=function(a,b){$.ajax({url:"/user/"+a,type:"DELETE"}).done(function(a){json=JSON.parse(a),b("del")}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.container_list=function(a){$.ajax({url:"/container/",type:"GET"}).done(function(b){var c=[];json=JSON.parse(b),$.each(json,function(a,b){c.push({id:b.id,session:b.session,owner:b.owner})}),a(c)}).fail(function(a){console.info(a.status+": "+a.statusText)})},api.container_delete=function(a,b){$.ajax({url:"/container/"+a,type:"DELETE"}).done(function(a){json=JSON.parse(a),b()}).fail(function(a){console.info(a.status+": "+a.statusText)})};var ui={};ui.bind_login_session=function(a){var b="";a.push({name:"Admin Panel",value:"admin-panel"}),(""==$("#login-session").val()||""==$("#login-session-name").text().trim())&&($("#login-session-name").text(a[0].name),$("#login-session").val(a[0].value)),$.each(a,function(a,c){b+="
  • ",b+='',b+=c.name,b+="",b+="
  • "}),$(".dropdown-menu").html(b)},ui.bind_user_data=function(a){var b="";$.each(a,function(a,c){b+="",b+=""+c.id+"",b+=""+c.name+"",b+=""+c.volume.join("
    ")+"",b+="admin"==c.name?'':'',b+=""}),""==b&&(b='No data'),$("#tbody-account").html(b),$("button.btn-remove-user").on("click",null,ui.model_delete_user)},ui.bind_container_data=function(a){var b="";$.each(a,function(a,c){b+="",b+=""+c.id+"",b+=""+c.session+"",b+=""+c.owner+"",b+='',b+=""}),""==b&&(b='No data'),$("#tbody-container").html(b),$("button.btn-remove-container").on("click",null,ui.model_delete_container)},ui.model_delete_user=function(){var a=$(this).closest("td").prev("td").prev("td"),b=a.prev("td").text(),c=a.text();$("#del-user-modal .modal-body > p").text(c),$("#del-user-id").val(b),$("#del-user-modal").modal("show")},ui.model_delete_container=function(){var a=$(this).closest("td").prev("td"),b=a.prev("td").prev("td").text(),c=a.prev("td").text(),d=a.text();a=$("#del-container-modal .modal-body > p"),a.text(b),a.next("p").text(c),a.next("p").next("p").text(d),$("#del-container-id").val(b),$("#del-container-modal").modal("show")},ui.do_switch_navbar=function(){var a,b=!1;$("#navbar > ul > li").each(function(){a=$(this).children("a").prop("hash"),window.location.hash==a?(b=!0,$(this).addClass("active"),$("#layout-"+a.substring(1)).show()):($(this).removeClass("active"),$("#layout-"+a.substring(1)).hide())}),b||(a=$("#navbar > ul > li:first-child a").prop("hash"),$("#navbar > ul > li:first-child").addClass("active"),$("#layout-"+a.substring(1)).show())},ui.do_session_login=function(a){return void 0!=a.error?void $("#login-msg").text(a.error.code+": "+a.error.message):("admin-panel"==a.session&&a.user.isAdmin?api.user_list(ui.bind_user_data):window.location="ubuntu-trusty-lxde"==a.session?"/u/"+a.session+"/vnc_auto.html?width="+(window.innerWidth-16)+"&height="+(window.innerHeight-16):"/u/"+a.session+"/",void $("#login-modal").modal("hide"))},ui.do_user_login=function(){var a=$("#login-username").val(),b=$("#login-password").val(),c=$("#login-session").val();api.login(a,b,c,ui.do_session_login)},ui.do_add_user=function(){var a=$("#add-username").val(),b=$("#add-password").val();api.user_add(a,b,ui.do_refresh_user_list)},ui.do_delete_user=function(){var a=$("#del-user-id").val();api.user_delete(a,ui.do_refresh_user_list)},ui.do_refresh_user_list=function(a){api.user_list(ui.bind_user_data),$("#"+a+"-user-modal").modal("hide")},ui.do_delete_container=function(){var a=$("#del-container-id").val();api.container_delete(a,ui.do_refresh_container_list)},ui.do_refresh_container_list=function(){api.container_list(ui.bind_container_data),$("#del-container-modal").modal("hide")},$(window).hashchange(function(){ui.do_switch_navbar()}),$(".dropdown-menu").on("click","li a",function(){$("#login-session-name").text($(this).text()),$("#login-session").val($(this).attr("data-value"))}),$("#btn-sign-in").on("click",null,ui.do_user_login),$("#btn-add-user").on("click",null,ui.do_add_user),$("#btn-del-user").on("click",null,ui.do_delete_user),$("#btn-del-container").on("click",null,ui.do_delete_container); \ No newline at end of file diff --git a/web/static/scripts/2c80432f.plugins.js b/web/static/scripts/2c80432f.plugins.js new file mode 100644 index 0000000..2c89978 --- /dev/null +++ b/web/static/scripts/2c80432f.plugins.js @@ -0,0 +1,2 @@ ++function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=this.unpin=this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.0",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return c>e?"top":!1;if("bottom"==this.affixed)return null!=c?e+this.unpin<=f.top?!1:"bottom":a-d>=e+g?!1:"bottom";var h=null==this.affixed,i=h?e:f.top,j=h?g:b;return null!=c&&c>=i?"top":null!=d&&i+j>=a-d?"bottom":!1},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=a("body").height();"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.0",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.0",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(this.options.viewport.selector||this.options.viewport);for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c&&c.$tip&&c.$tip.is(":visible")?void(c.hoverState="in"):(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.options.container?a(this.options.container):this.$element.parent(),p=this.getPosition(o);h="bottom"==h&&k.bottom+m>p.bottom?"top":"top"==h&&k.top-mp.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.width&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){return this.$tip=this.$tip||a(this.options.template)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type)})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b,d){return this.each(function(){var e=a(this),f=e.data("bs.modal"),g=a.extend({},c.DEFAULTS,e.data(),"object"==typeof b&&b);f||e.data("bs.modal",f=new c(this,g)),"string"==typeof b?f[b](d):g.show&&f.show(d)})}var c=function(b,c){this.options=c,this.$body=a(document.body),this.$element=a(b),this.$backdrop=this.isShown=null,this.scrollbarWidth=0,this.options.remote&&this.$element.find(".modal-content").load(this.options.remote,a.proxy(function(){this.$element.trigger("loaded.bs.modal")},this))};c.VERSION="3.3.0",c.TRANSITION_DURATION=300,c.BACKDROP_TRANSITION_DURATION=150,c.DEFAULTS={backdrop:!0,keyboard:!0,show:!0},c.prototype.toggle=function(a){return this.isShown?this.hide():this.show(a)},c.prototype.show=function(b){var d=this,e=a.Event("show.bs.modal",{relatedTarget:b});this.$element.trigger(e),this.isShown||e.isDefaultPrevented()||(this.isShown=!0,this.checkScrollbar(),this.$body.addClass("modal-open"),this.setScrollbar(),this.escape(),this.$element.on("click.dismiss.bs.modal",'[data-dismiss="modal"]',a.proxy(this.hide,this)),this.backdrop(function(){var e=a.support.transition&&d.$element.hasClass("fade");d.$element.parent().length||d.$element.appendTo(d.$body),d.$element.show().scrollTop(0),e&&d.$element[0].offsetWidth,d.$element.addClass("in").attr("aria-hidden",!1),d.enforceFocus();var f=a.Event("shown.bs.modal",{relatedTarget:b});e?d.$element.find(".modal-dialog").one("bsTransitionEnd",function(){d.$element.trigger("focus").trigger(f)}).emulateTransitionEnd(c.TRANSITION_DURATION):d.$element.trigger("focus").trigger(f)}))},c.prototype.hide=function(b){b&&b.preventDefault(),b=a.Event("hide.bs.modal"),this.$element.trigger(b),this.isShown&&!b.isDefaultPrevented()&&(this.isShown=!1,this.escape(),a(document).off("focusin.bs.modal"),this.$element.removeClass("in").attr("aria-hidden",!0).off("click.dismiss.bs.modal"),a.support.transition&&this.$element.hasClass("fade")?this.$element.one("bsTransitionEnd",a.proxy(this.hideModal,this)).emulateTransitionEnd(c.TRANSITION_DURATION):this.hideModal())},c.prototype.enforceFocus=function(){a(document).off("focusin.bs.modal").on("focusin.bs.modal",a.proxy(function(a){this.$element[0]===a.target||this.$element.has(a.target).length||this.$element.trigger("focus")},this))},c.prototype.escape=function(){this.isShown&&this.options.keyboard?this.$element.on("keydown.dismiss.bs.modal",a.proxy(function(a){27==a.which&&this.hide()},this)):this.isShown||this.$element.off("keydown.dismiss.bs.modal")},c.prototype.hideModal=function(){var a=this;this.$element.hide(),this.backdrop(function(){a.$body.removeClass("modal-open"),a.resetScrollbar(),a.$element.trigger("hidden.bs.modal")})},c.prototype.removeBackdrop=function(){this.$backdrop&&this.$backdrop.remove(),this.$backdrop=null},c.prototype.backdrop=function(b){var d=this,e=this.$element.hasClass("fade")?"fade":"";if(this.isShown&&this.options.backdrop){var f=a.support.transition&&e;if(this.$backdrop=a('