#!/usr/bin/env python3
#
# Copyright (C) 2019 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
#

import os
import sys
import grp
import json
import traceback
import threading

import vyos.config

import bottle

from functools import wraps

from vyos.configsession import ConfigSession, ConfigSessionError
from vyos.config import VyOSError


DEFAULT_CONFIG_FILE = '/etc/vyos/http-api.conf'
CFG_GROUP = 'vyattacfg'

app = bottle.default_app()

# Giant lock!
lock = threading.Lock()

def load_server_config():
    with open(DEFAULT_CONFIG_FILE) as f:
        config = json.load(f)
    return config

def check_auth(key_list, key):
    id = None
    for k in key_list:
        if k['key'] == key:
            id = k['id']
    return id

def error(code, msg):
    bottle.response.status = code
    resp = {"success": False, "error": msg, "data": None}
    return json.dumps(resp)

def success(data):
    resp = {"success": True, "data": data, "error": None}
    return json.dumps(resp)

def auth_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        key = bottle.request.forms.get("key")
        api_keys = app.config['vyos_keys']
        id = check_auth(api_keys, key)
        if not id:
            return error(401, "Valid API key is required")
        return f(*args, **kwargs)

    return decorated_function

@app.route('/configure', method='POST')
@auth_required
def configure():
    session = app.config['vyos_session']
    config = app.config['vyos_config']

    strict_field = bottle.request.forms.get("strict")
    if strict_field == "true":
        strict = True
    else:
        strict = False

    commands = bottle.request.forms.get("data")
    if not commands:
        return error(400, "Non-empty data field is required")
    else:
        try:
            commands = json.loads(commands)
        except Exception as e:
            return error(400, "Failed to parse JSON: {0}".format(e))

    # Allow users to pass just one command
    if not isinstance(commands, list):
        commands = [commands]

    # We don't want multiple people/apps to be able to commit at once,
    # or modify the shared session while someone else is doing the same,
    # so the lock is really global
    lock.acquire()

    status = 200
    error_msg = None
    try:
        for c in commands:
            # What we've got may not even be a dict
            if not isinstance(c, dict):
                raise ConfigSessionError("Malformed command \"{0}\": any command must be a dict".format(json.dumps(c)))

            # Missing op or path is a show stopper
            if not ('op' in c):
                raise ConfigSessionError("Malformed command \"{0}\": missing \"op\" field".format(json.dumps(c)))
            if not ('path' in c):
                raise ConfigSessionError("Malformed command \"{0}\": missing \"path\" field".format(json.dumps(c)))

            # Missing value is fine, substitute for empty string
            if 'value' in c:
                value = c['value']
            else:
                value = ""

            op = c['op']
            path = c['path']

            if not path:
                raise ConfigSessionError("Malformed command \"{0}\": empty path".format(json.dumps(c)))

            # Type checking
            if not isinstance(path, list):
                raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list".format(json.dumps(c)))

            if not isinstance(value, str):
                raise ConfigSessionError("Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(c)))

            # Account for the case when value field is present and set to null
            if not value:
                value = ""

            # For vyos.configsessios calls that have no separate value arguments,
            # and for type checking too
            try:
                cfg_path = " ".join(path + [value]).strip()
            except TypeError:
                raise ConfigSessionError("Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(c)))

            if op == 'set':
                # XXX: it would be nice to do a strict check for "path already exists",
                # but there's probably no way to do that
                session.set(path, value=value)
            elif op == 'delete':
                if strict and not config.exists(cfg_path):
                    raise ConfigSessionError("Cannot delete [{0}]: path/value does not exist".format(cfg_path))
                session.delete(path, value=value)
            elif op == 'comment':
                session.comment(path, value=value)
            else:
                raise ConfigSessionError("\"{0}\" is not a valid operation".format(op))
        # end for
        session.commit()
        print("Configuration modified via HTTP API using key \"{0}\"".format(id))
    except ConfigSessionError as e:
        session.discard()
        status = 400
        if app.config['vyos_debug']:
            print(traceback.format_exc(), file=sys.stderr)
        error_msg = str(e)
    except Exception as e:
        session.discard()
        print(traceback.format_exc(), file=sys.stderr)
        status = 500

        # Don't give the details away to the outer world
        error_msg = "An internal error occured. Check the logs for details."
    finally:
        lock.release()

    if status != 200:
        return error(status, error_msg)
    else:
        return success(None)

@app.route('/retrieve', method='POST')
@auth_required
def get_value():
    config = app.config['vyos_config']
    session = app.config['vyos_session']

    command = bottle.request.forms.get("data")
    command = json.loads(command)

    try:
        op = command['op']
        path = " ".join(command['path'])
    except KeyError:
        return error(400, "Missing required field. \"op\" and \"path\" fields are required")

    try:
        if op == 'returnValue':
            res = config.return_value(path)
        elif op == 'returnValues':
            res = config.return_values(path)
        elif op == 'exists':
            res = config.exists(path)
        elif op == 'showConfig':
            config_format = 'json'
            if 'configFormat' in command:
                config_format = command['configFormat']

            res = session.show_config(path=command['path'])
            if config_format == 'json':
                config_tree = vyos.configtree.ConfigTree(res)
                res = json.loads(config_tree.to_json())
            elif config_format == 'json_ast':
                config_tree = vyos.configtree.ConfigTree(res)
                res = json.loads(config_tree.to_json_ast())
            elif config_format == 'raw':
                pass
            else:
                return error(400, "\"{0}\" is not a valid config format".format(config_format))
        else:
            return error(400, "\"{0}\" is not a valid operation".format(op))
    except VyOSError as e:
        return error(400, str(e))
    except Exception as e:
        print(traceback.format_exc(), file=sys.stderr)
        return error(500, "An internal error occured. Check the logs for details.")

    return success(res)

@app.route('/config-file', method='POST')
@auth_required
def config_file_op():
    config = app.config['vyos_config']
    session = app.config['vyos_session']

    command = bottle.request.forms.get("data")
    command = json.loads(command)

    try:
        op = command['op']
    except KeyError:
        return error(400, "Missing required field \"op\"")

    try:
        if op == 'save':
            try:
                path = command['file']
            except KeyError:
                path = '/config/config.boot'
            res = session.save_config(path)
        elif op == 'load':
            try:
                path = command['file']
            except KeyError:
                return error(400, "Missing required field \"file\"")
            res = session.load_config(path)
            res = session.commit()
        else:
            return error(400, "\"{0}\" is not a valid operation".format(op))
    except VyOSError as e:
        return error(400, str(e))
    except Exception as e:
        print(traceback.format_exc(), file=sys.stderr)
        return error(500, "An internal error occured. Check the logs for details.")

    return success(res)

@app.route('/image', method='POST')
@auth_required
def config_file_op():
    config = app.config['vyos_config']
    session = app.config['vyos_session']

    command = bottle.request.forms.get("data")
    command = json.loads(command)

    try:
        op = command['op']
    except KeyError:
        return error(400, "Missing required field \"op\"")

    try:
        if op == 'add':
            try:
                url = command['url']
            except KeyError:
                return error(400, "Missing required field \"url\"")
            res = session.install_image(url)
        elif op == 'delete':
            try:
                name = command['name']
            except KeyError:
                return error(400, "Missing required field \"name\"")
            res = session.remove_image(name)
        else:
            return error(400, "\"{0}\" is not a valid operation".format(op))
    except VyOSError as e:
        return error(400, str(e))
    except Exception as e:
        print(traceback.format_exc(), file=sys.stderr)
        return error(500, "An internal error occured. Check the logs for details.")

    return success(res)


if __name__ == '__main__':
    # systemd's user and group options don't work, do it by hand here,
    # else no one else will be able to commit
    cfg_group = grp.getgrnam(CFG_GROUP)
    os.setgid(cfg_group.gr_gid)

    # Need to set file permissions to 775 too so that every vyattacfg group member
    # has write access to the running config
    os.umask(0o002)

    try:
        server_config = load_server_config()
    except Exception as e:
        print("Failed to load the HTTP API server config: {0}".format(e))

    session = ConfigSession(os.getpid())
    env = session.get_session_env()
    config = vyos.config.Config(session_env=env)

    app.config['vyos_session'] = session
    app.config['vyos_config'] = config
    app.config['vyos_keys'] = server_config['api_keys']
    app.config['vyos_debug'] = server_config['debug']

    bottle.run(app, host=server_config["listen_address"], port=server_config["port"], debug=True)