diff options
| author | Jon Bergli Heier <snakebite@jvnv.net> | 2017-04-09 09:02:09 +0200 | 
|---|---|---|
| committer | Jon Bergli Heier <snakebite@jvnv.net> | 2017-04-09 09:02:09 +0200 | 
| commit | b36f9c05071ea549ed59e703270fcf223b60df03 (patch) | |
| tree | 8992c6bcaa5b0d64cbd589588b2539523125548c /fbin | |
| parent | af750a6598d53b8a5cb58092dd5b523ea7e967ca (diff) | |
Major rewrite to use jab/oauth.
Highlights:
- Uses the oauth branch of jab.
- Changed design to use bootstrap.
- Some minor changes to functionality in file uploading and listing.
- API is currently disabled and incomplete.
Diffstat (limited to 'fbin')
26 files changed, 1335 insertions, 0 deletions
| diff --git a/fbin/__init__.py b/fbin/__init__.py new file mode 100644 index 0000000..bdeaa5d --- /dev/null +++ b/fbin/__init__.py @@ -0,0 +1,39 @@ +from flask import Flask, url_for, Markup, request +from flask_login import current_user +from werkzeug.routing import BaseConverter + +app = Flask(__name__) +app.config.from_pyfile('fbin.cfg') + +# Set up some custom converters. These are needed for file URLs to be properly parsed. + +class HashConverter(BaseConverter): +    regex = r'\w+' + +class ExtensionConverter(BaseConverter): +    regex = r'\.\w+' + +app.url_map.converters['hash'] = HashConverter +app.url_map.converters['ext'] = ExtensionConverter + +@app.context_processor +def context_processors(): +    def nav_html(view, name=None): +        url = url_for(view) +        if not name: +            name = view.rsplit('.', 1)[-1].replace('_', ' ').capitalize() +        if view == '.logout': +            name += ' [{}]'.format(current_user.username) +        return Markup('<li{}><a href="{}">{}</a></li>'.format(' class="active"' if url == request.path else '', url, name)) +    return { +        'nav_html': nav_html, +    } + +with app.app_context(): +    # TODO: Enable API when done +    from .fbin import app as fbin +    #from .api import app as api +    from .login import login_manager +    app.register_blueprint(fbin) +    #app.register_blueprint(api, url_prefix = '/api') +    login_manager.init_app(app) diff --git a/fbin/api.py b/fbin/api.py new file mode 100644 index 0000000..e652019 --- /dev/null +++ b/fbin/api.py @@ -0,0 +1,62 @@ +import functools + +from flask import Blueprint, current_app, request, jsonify +from flask.views import MethodView +from flask_login import current_user + +from . import db +# FIXME +from .fbin import get_file + +app = Blueprint('api', __name__) + +# TODO: Implement this stuff. + +def makejson(f): +    @functools.wraps(f) +    def wrapper(*args, **kwargs): +        r = f(*args, **kwargs) +        if isinstance(r, dict): +            r = jsonify(r) +        return r +    return wrapper + +def api_login_required(f): +    def wrapper(*args, **kwargs): +        if not current_user.is_authenticated: +            return { +                'status': False, +                'message': 'Not authenticated' +            } +        return f(*args, **kwargs) +    return wrapper + +class FileAPI(MethodView): +    decorators = [api_login_required, makejson] + +    def put(self, hash): +        f = get_file(hash, user_id = current_user.get_id()) +        if not f: +            return { +                'status': False, +                'message': 'File not found' +            } +        filename = request.form.get('filename') +        if not filename: +            return { +                'status': False, +                'message': 'Empty or missing filename', +            } +        with db.session_scope() as sess: +            f.filename = filename +            sess.add(f) +        return { +            'status': True, +        } + +    def delete(self, hash): +        pass + +file_api_view = FileAPI.as_view('file_api') +app.add_url_rule('/file/<hash>', view_func = file_api_view, methods = ['PUT', 'DELETE']) + diff --git a/fbin/db.py b/fbin/db.py new file mode 100644 index 0000000..dfe3b17 --- /dev/null +++ b/fbin/db.py @@ -0,0 +1,121 @@ +from contextlib import contextmanager +import mimetypes +import os + +from flask import current_app +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, Index, ForeignKey, Boolean +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relation, backref +from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql import and_ + +engine = create_engine(current_app.config['DB_URI']) + +Base = declarative_base(bind = engine) + +class User(Base): +    __tablename__ = 'users' + +    id = Column(Integer, primary_key = True) +    username = Column(String, unique = True, index = True) +    jab_id = Column(String(24), unique = True, index = True) +    files = relation('File', backref = 'user', order_by = 'File.date.desc()') + +    def __init__(self, username, jab_id): +        self.username = username +        self.jab_id = jab_id + +class UserSession(Base): +    __tablename__ = 'sessions' + +    id = Column(Integer, primary_key = True) +    user_id = Column(Integer, ForeignKey('users.id'), index = True) +    access_token = Column(String) +    refresh_token = Column(String) +    updated = Column(DateTime) + +    def __init__(self, user_id, access_token, refresh_token): +        self.user_id = user_id +        self.access_token = access_token +        self.refresh_token = refresh_token + +class File(Base): +    __tablename__ = 'files' + +    id = Column(Integer, primary_key = True) +    hash = Column(String, unique = True, index = True) +    filename = Column(String) +    date = Column(DateTime) +    user_id = Column(Integer, ForeignKey('users.id'), nullable = True) +    ip = Column(String) +    accessed = Column(DateTime) + +    def __init__(self, hash, filename, date, user_id = None, ip = None): +        self.hash = hash +        self.filename = filename +        self.date = date +        self.user_id = user_id +        self.ip = ip + +    @staticmethod +    def pretty_size(size): +        if size is None: +            return 'N/A' +        suffixes = (('TiB', 2**40), ('GiB', 2**30), ('MiB', 2**20), ('KiB', 2**10)) +        for suf, threshold in suffixes: +            if size >= threshold: +                return '{:.2f} {}'.format(size / threshold, suf) +            else: +                continue +        return '{} B'.format(size) + +    def get_path(self): +        return os.path.join(current_app.config['FILE_DIRECTORY'], self.hash + os.path.splitext(self.filename)[1]) + +    def get_thumb_path(self): +        return os.path.join(current_app.config['THUMB_DIRECTORY'], self.hash + '.jpg') + +    def get_size(self): +        try: +            return os.path.getsize(self.get_path()) +        except OSError: +            return None + +    @property +    def formatted_size(self): +        return self.pretty_size(self.get_size()) + +    @property +    def formatted_date(self): +        return self.date.strftime('%Y-%m-%d %H:%M:%S UTC') + +    def get_mime_type(self): +        return mimetypes.guess_type(self.filename, strict = False)[0] or 'application/octet-stream' + +    def is_image(self): +        return self.get_mime_type().startswith('image') + +    @property +    def ext(self): +        return os.path.splitext(self.filename)[1] + +    @property +    def exists(self): +        return os.path.exists(self.get_path()) + +Base.metadata.create_all() +Session = sessionmaker(bind = engine, autoflush = True, autocommit = False) + +@contextmanager +def session_scope(): +    session = Session() +    try: +        session.expire_on_commit = False +        yield session +        session.commit() +    except: +        session.rollback() +        raise +    finally: +        session.close() diff --git a/fbin/fbin.py b/fbin/fbin.py new file mode 100755 index 0000000..42c371f --- /dev/null +++ b/fbin/fbin.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python + +import base64 +import cgi +import datetime +import hashlib +import io +import json +import mimetypes +import os +import random +import subprocess +import tempfile +import urllib +from urllib.parse import urlencode, urljoin + +from flask import Blueprint, redirect, current_app, url_for, request, render_template, session, flash, send_file, abort, jsonify, Markup +from flask_login import login_user, logout_user, current_user, login_required +import jwt +from PIL import Image +import requests +from werkzeug.utils import secure_filename + +from . import db +from .monkey import patch as monkey_patch +from .login import login_manager, load_user + +monkey_patch() + +base62_alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + +if not os.path.isdir(current_app.config['FILE_DIRECTORY']): +    os.mkdir(current_app.config['FILE_DIRECTORY']) + +if not os.path.isdir(current_app.config['THUMB_DIRECTORY']): +    os.mkdir(current_app.config['THUMB_DIRECTORY']) + +try: +    # Throws OSError if mogrify doesn't exist. +    subprocess.call(['mogrify', '-quiet']) +except OSError: +    has_mogrify = False +else: +    has_mogrify = True + +def get_or_create_user(username, jab_id): +    with db.session_scope() as sess: +        try: +            return sess.query(db.User).filter(db.User.jab_id == jab_id).one() +        except db.NoResultFound: +            try: +                user = db.User(username, jab_id) +                sess.add(user) +                sess.commit() +                sess.refresh(user) +                return user +            except db.IntegrityError: +                return None + +def add_file(path, filename, user = None, ip = None): +    file_hash = ''.join(random.choice(base62_alphabet) for x in range(5)) +    new_path = os.path.join(current_app.config['FILE_DIRECTORY'], file_hash + os.path.splitext(filename)[1]) +    os.rename(path, new_path) +    if current_app.config.get('DESTINATION_MODE'): +        os.chmod(new_path, current_app.config.get('DESTINATION_MODE')) +    with db.session_scope() as sess: +        f = db.File(file_hash, filename, datetime.datetime.utcnow(), user.id if user else None, ip) +        sess.add(f) +        sess.commit() +        sess.refresh(f) +    return f + +def get_file(file_hash, user_id=None, update_accessed=False): +    with db.session_scope() as sess: +        try: +            f = sess.query(db.File).filter(db.File.hash == file_hash) +            if user_id: +                f = f.filter(db.File.user_id == user_id) +            f = f.one() +        except db.NoResultFound: +            return None +        if update_accessed: +            f.accessed = datetime.datetime.utcnow() +            sess.add(f) +            sess.commit() +            # Refresh after field update. +            sess.refresh(f) +        return f + +def get_files(user): +    with db.session_scope() as sess: +        try: +            sess.add(user) +            files = user.files +        except db.NoResultFound: +            return [] +    return files + +def delete_file(file): +    with db.session_scope() as sess: +        sess.delete(file) +        sess.commit() +        filename = file.get_path() +        if os.path.exists(filename): +            os.unlink(filename) +        thumbfile = file.get_thumb_path() +        if os.path.exists(thumbfile): +            os.unlink(thumbfile) + +app = Blueprint('fbin', __name__) + +@app.route('/') +def index(): +    return redirect(url_for('.upload')) + +@app.route('/u') +@app.route('/upload', methods = ['GET', 'POST']) +def upload(): +    context = { +        'title': 'Upload', +    } +    if request.method == 'GET': +        return render_template('upload.html', **context) +    if not current_user.is_authenticated and not current_app.config.get('ALLOW_ANONYMOUS_UPLOADS'): +        abort(403) +    uploaded_file = request.files.get('file') +    if not uploaded_file or not uploaded_file.filename: +        flash('No valid file or filename was provided.', 'warning') +        return render_template('upload.html', **context) +    if hasattr(uploaded_file.stream, 'file'): +        temp = None +        temp_path = uploaded_file.stream.name +    else: +        temp = tempfile.NamedTemporaryFile(prefix = 'upload_', dir = current_app.config['FILE_DIRECTORY'], delete = False) +        uploaded_file.save(temp.file) +        temp_path = temp.name +    new_file = add_file(temp_path, uploaded_file.filename, current_user.user if current_user.is_authenticated else None, request.remote_addr) + +    mime = new_file.get_mime_type() +    # TODO: Apparently TIFF also supports EXIF, test this. +    if has_mogrify and mime == 'image/jpeg': +        # NOTE: PIL doesn't support lossless rotation, so we call mogrify to do this. +        # NOTE: This changes the file, so the file_hash applies to the ORIGINAL file contents only. +        # NOTE: The file hash is only used to detect duplicates when uploading, so this should not be a problem. +        subprocess.call(['mogrify', '-auto-orient', new_file.get_path()]) + +    if bool(request.form.get('api')): +        return 'OK {hash}'.format(hash = new_file.hash) +    else: +        context = { +            'file': new_file, +        } +        return redirect(url_for('.uploaded', hash = new_file.hash)) + +@app.route('/uploaded/<hash>') +def uploaded(hash): +    f = get_file(hash, update_accessed = False) +    if not f: +        abort(404) +    if f.user_id and (not current_user.is_authenticated or f.user_id != current_user.get_user_id()): +        abort(404) +    context = { +        'title': 'Uploaded', +        'subtitle': f.filename, +        'file': f, +    } +    return render_template('uploaded.html', **context) + +@app.route('/f/<hash:hash>') +@app.route('/f/<hash:hash><ext:ext>') +@app.route('/f/<hash:hash>/<path:filename>') +@app.route('/file/<hash:hash>', endpoint = 'file') +@app.route('/file/<hash:hash><ext:ext>', endpoint = 'file') +@app.route('/file/<hash:hash>/<path:filename>', endpoint = 'file') +def _file(hash, ext=None, filename=None): +    f = get_file(hash) +    if not f: +        abort(404) +    return send_file(f.get_path()) + +@app.route('/l') +@app.route('/login') +def login(): +    session['oauth_state'] = base64.urlsafe_b64encode(os.urandom(30)).decode() +    return redirect(urljoin(current_app.config['OAUTH_URL'], 'authorize') + '?' + urlencode({ +        'response_type': 'code', +        'client_id': current_app.config['OAUTH_CLIENT_ID'], +        'redirect_uri': urljoin(request.host_url, url_for('.auth')), +        'state': session['oauth_state'], +    })) + +@app.route('/account') +def account(): +    return redirect(current_app.config['ACCOUNT_URL']) + +@app.route('/o') +@app.route('/logout') +def logout(): +    if not current_user.is_authenticated: +        return redirect(url_for('.index')) +    session_id = int(current_user.get_id().split(':', 1)[-1]) +    with db.session_scope() as s: +        try: +            s.query(db.UserSession).filter_by(id = session_id).delete() +        except: +            raise +    logout_user() +    return redirect(url_for('.index')) + +@app.route('/auth') +def auth(): +    if 'error' in request.args: +        error = request.args['error'] +        error_description = request.args.get('error_description') +        msg = 'OAuth error: {}'.format(error) +        if error_description: +            msg += ' ({})'.format(error_description) +        flash(msg, 'error') +        return redirect(url_for('.index')) +    session_state = session.pop('oauth_state', None) +    state = request.args.get('state') +    if session_state != state: +        flash('Invalid OAuth state', 'error') +        return redirect(url_for('.index')) +    code = request.args.get('code') +    if not code: +        flash('Missing OAuth code', 'error') +        return redirect(url_for('.index')) +    rs = requests.Session() +    response = rs.post(urljoin(current_app.config['OAUTH_URL'], 'token'), data = { +        'grant_type': 'authorization_code', +        'code': code, +        'client_id': current_app.config['OAUTH_CLIENT_ID'], +        'client_secret': current_app.config['OAUTH_CLIENT_SECRET'], +        'redirect_uri': urljoin(request.host_url, url_for('.auth')), +    }) +    token = response.json() +    if 'error' in token: +        msg = 'OAuth error: {}'.format(token['error']) +        flash(msg, 'error') +        return redirect(url_for('.index')) +    try: +        access_data = jwt.decode(token['access_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID']) +        refresh_data = jwt.decode(token['refresh_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID']) +    except jwt.InvalidTokenError as e: +        flash('Failed to verify token: {!s}'.format(e), 'error') +        return redirect(url_for('.index')) +    response = rs.get(urljoin(current_app.config['OAUTH_URL'], '/api/user'), headers = {'Authorization': 'Bearer {}'.format(token['access_token'])}) +    user = response.json() +    user = get_or_create_user(user['username'], user['id']) +    with db.session_scope() as s: +        us = db.UserSession(user.id, token['access_token'], token['refresh_token']) +        us.updated = datetime.datetime.utcnow() +        s.add(us) +        s.commit() +        s.refresh(us) +    user = load_user('{}:{}'.format(user.id, us.id)) +    if not user: +        flash('Failed to retrieve user instance.', 'error') +    else: +        login_user(user, remember = True) +    return redirect(url_for('.index')) + +@app.route('/m') +@app.route('/files') +@login_required +def files(): +    files = get_files(current_user.user) +    context = { +        'title': 'Files', +        'files': files, +        'total_size': db.File.pretty_size(sum(f.get_size() for f in files if f.exists)), +    } +    return render_template('files.html', **context) + +@app.route('/files', methods = ['POST']) +@login_required +def file_edit(): +    f = get_file(request.form.get('hash'), user_id = current_user.get_id(), update_accessed = False) +    if not f: +        flash('File not found.', 'error') +        return redirect(url_for('.files')) +    if 'filename' in request.form: +        with db.session_scope() as sess: +            old_path = f.get_path() +            filename = request.form.get('filename', f.filename) +            f.filename = filename +            new_path = f.get_path() +            # If extension changed, the local filename also changes. We could just store the file without the extension, +            # but that would break the existing files, requiring a manual rename. +            if old_path != new_path: +                try: +                    if os.path.exists(new_path): +                        # This shouldn't happen unless we have two files with the same hash, which should be impossible. +                        raise RuntimeError() +                    else: +                        os.rename(old_path, new_path) +                except: +                    flash(Markup('Internal rename failed; file may have become unreachable. ' +                    'Please contact an admin and specify <strong>hash={}</strong>.'.format(f.hash)), 'error') +            sess.add(f) +            flash('Filename changed to "{}".'.format(f.filename), 'success') +    elif 'delete' in request.form: +        try: +            delete_file(f) +        except: +            flash('Failed to delete file.', 'error') +        else: +            flash('File deleted.', 'success') +    else: +        flash('No action was performed.', 'warning') +    return redirect(url_for('.files')) + +@app.route('/i') +@app.route('/images') +@login_required +def images(): +    files = [f for f in get_files(current_user.user) if f.is_image()] +    context = { +        'title': 'Images', +        'fullwidth': True, +        'files': files, +        'total_size': db.File.pretty_size(sum(f.get_size() for f in files if f.exists)), +    } +    return render_template('images.html', **context) + +@app.route('/t/<hash:hash>') +@app.route('/thumb/<hash:hash>') +def thumb(hash): +    thumbfile = os.path.join(current_app.config['THUMB_DIRECTORY'], hash + '.jpg') +    if not os.access(thumbfile, os.F_OK): +        f = get_file(hash, update_accessed = False) +        try: +            im = Image.open(f.get_path()) +        except IOError: +            # We can't generate a thumbnail for this file, just say it doesn't exist. +            abort(404) +        # Check for valid JPEG modes. +        if im.mode not in ('1', 'L', 'RGB', 'RGBA', 'RGBX', 'CMYK', 'YCbCr'): +            im = im.convert('RGB') +        im.thumbnail(current_app.config.get('THUMB_SIZE', (128, 128)), Image.ANTIALIAS) +        im.save(thumbfile) +    return send_file(thumbfile) + +@app.route('/h') +@app.route('/help') +def help(): +    context = { +        'title': 'Help', +    } +    return render_template('help.html', **context) + +login_manager.login_view = '.login' diff --git a/fbin/login.py b/fbin/login.py new file mode 100644 index 0000000..7647e13 --- /dev/null +++ b/fbin/login.py @@ -0,0 +1,92 @@ +import datetime +import traceback +from urllib.parse import urljoin + +from flask import current_app, flash +from flask_login import LoginManager +import jwt +import requests + +from . import db + +login_manager = LoginManager() + +class User: +    def __init__(self, user, user_session): +        self.user = user +        self.user_session = user_session +        self.token = None + +    def refresh_access_token(self): +        response = requests.post(urljoin(current_app.config['OAUTH_URL'], 'token'), data = { +            'grant_type': 'refresh_token', +            'client_id': current_app.config['OAUTH_CLIENT_ID'], +            'client_secret': current_app.config['OAUTH_CLIENT_SECRET'], +            'refresh_token': self.user_session.refresh_token, +        }) +        if response.status_code != 200: +            flash('Failed to refresh authentication token (API call returned {} {})'.format(response.status_code, response.reason), 'error') +            return +        token = response.json() +        try: +            access_data = jwt.decode(token['access_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID']) +            refresh_data = jwt.decode(token['refresh_token'], key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID']) +        except jwt.InvalidTokenError as e: +            traceback.print_exc() +            flash('Failed to refresh authentication token (verification failed)', 'error') +            return +        with db.session_scope() as sess: +            self.user_session.access_token = token['access_token'] +            self.user_session.refresh_token = token['refresh_token'] +            self.user_session.updated = datetime.datetime.utcnow() +            sess.add(self.user_session) +        return True + +    @property +    def is_authenticated(self): +        if self.user is None: +            return False +        if self.token: +            return True +        try: +            self.token = jwt.decode(self.user_session.access_token, key = current_app.config['JWT_PUBLIC_KEY'], audience = current_app.config['OAUTH_CLIENT_ID']) +        except jwt.ExpiredSignatureError: +            try: +                if not self.refresh_access_token(): +                    return False +            except: +                traceback.print_exc() +                flash('Failed to refresh authentication token (unhandled error; contact an admin)', 'error') +                return False +        except jwt.InvalidTokenError: +            return False +        return True + +    @property +    def is_active(self): +        return True + +    @property +    def is_anonymous(self): +        return False + +    def get_id(self): +        return '{}:{}'.format(self.user.id, self.user_session.id) + +    def get_user_id(self): +        return self.user.id if self.is_authenticated else None + +    @property +    def username(self): +        return self.user.username + +@login_manager.user_loader +def load_user(user_id): +    user_id, session_id = map(int, user_id.split(':', 1)) +    try: +        with db.session_scope() as sess: +            user, user_session = sess.query(db.User, db.UserSession).join(db.UserSession).filter(db.User.id == user_id, db.UserSession.id == session_id).one() +            return User(user, user_session) +    except: +        traceback.print_exc() +        return None diff --git a/fbin/monkey.py b/fbin/monkey.py new file mode 100644 index 0000000..c6458ad --- /dev/null +++ b/fbin/monkey.py @@ -0,0 +1,25 @@ +import tempfile + +from flask import current_app +import werkzeug.formparser +import werkzeug.wrappers + +def werkzeug_patch(): +    global werkzeug_orig_stream_factory + +    werkzeug_orig_stream_factory = werkzeug.formparser.default_stream_factory + +    def custom_stream_factory(total_content_length, filename, content_type, content_length=None): +        if total_content_length > 1024 * 500: +                return tempfile.NamedTemporaryFile('wb+', prefix = 'upload_', dir = current_app.config['FILE_DIRECTORY'], delete = True) +        return werkzeug_orig_stream_factory(total_content_length, filename, content_type, content_length) + +    werkzeug.formparser.default_stream_factory = custom_stream_factory +    werkzeug.wrappers.default_stream_factory = custom_stream_factory + +def werkzeug_reset(): +    werkzeug.formparser.default_stream_factory = werkzeug_orig_stream_factory +    werkzeug.wrappers.default_stream_factory = werkzeug_orig_stream_factory + +def patch(): +    werkzeug_patch() diff --git a/fbin/static/css/bootstrap.min.css b/fbin/static/css/bootstrap.min.css new file mode 100644 index 0000000..ed3905e --- /dev/null +++ b/fbin/static/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphico | 
