/*++
/* NAME
/*	dict_mongodb 3
/* SUMMARY
/*	dictionary interface to mongodb, compatible with libmongoc-1.0
/* SYNOPSIS
/*	#include <dict_mongodb.h>
/*
/*	DICT *dict_mongodb_open(name, open_flags, dict_flags)
/*	const char *name;
/*	int	open_flags;
/*	int	dict_flags;
/* DESCRIPTION
/*	dict_mongodb_open() opens a MongoDB database, providing a
/*	dictionary interface for Postfix mappings. The result is a
/*	pointer to the installed dictionary.
/*
/*	Configuration parameters are described in mongodb_table(5).
/*
/*	Arguments:
/* .IP name
/*	Either the path to the MongoDB configuration file (if it
/*	starts with '/' or '.'), or the prefix which will be used
/*	to obtain main.cf configuration parameters for this search.
/*
/*	In the first case, configuration parameters are specified
/*	in the file as \fIname\fR=\fIvalue\fR pairs.
/*
/*	In the second case, the configuration parameters are prefixed
/*	with the value of \fIname\fR and an underscore, and they
/*	are specified in main.cf. For example, if this value is
/*	\fImongodbconf\fR, the parameters would look like
/*	\fImongodbconf_uri\fR, \fImongodbconf_collection\fR, and
/*	so on.
/* .IP open_flags
/*	Must be O_RDONLY
/* .IP dict_flags
/*	See dict_open(3).
/* SEE ALSO
/*	dict(3) generic dictionary manager
/* HISTORY
/* .ad
/* .fi
/*	MongoDB support was added in Postfix 3.9.
/* AUTHOR(S)
/*	Hamid Maadani (hamid@dexo.tech)
/*	Dextrous Technologies, LLC
/*
/*	Edited by:
/*	Wietse Venema
/*	porcupine.org
/*
/*	Based on prior work by:
/*	Stephan Ferraro
/*	Aionda GmbH
/*--*/

 /*
  * System library.
  */
#include <sys_defs.h>
#ifdef HAS_MONGODB
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <ctype.h>
#include <inttypes.h>			/* C99 PRId64 */

#include <bson/bson.h>
#include <mongoc/mongoc.h>

 /*
  * Utility library.
  */
#include <dict.h>
#include <msg.h>
#include <mymalloc.h>
#include <vstring.h>
#include <stringops.h>
#include <auto_clnt.h>
#include <vstream.h>

 /*
  * Global library.
  */
#include <cfg_parser.h>
#include <db_common.h>

 /*
  * Application-specific.
  */
#include <dict_mongodb.h>

 /*
  * Initial size for dynamically-allocated buffers.
  */
#ifndef BUFFER_SIZE
#define BUFFER_SIZE 1024
#endif

#define INIT_VSTR(buf, len) do { \
	if (buf == 0) \
		buf = vstring_alloc(len); \
	VSTRING_RESET(buf); \
	VSTRING_TERMINATE(buf); \
    } while (0)

/* Structure of one mongodb dictionary handle. */
typedef struct {
    /* Initialized by dict_mongodb_open(). */
    DICT    dict;			/* Parent class */
    CFG_PARSER *parser;			/* Configuration file parser */
    mongoc_client_t *client;		/* Mongo C client handle */
    /* Initialized by mongodb_parse_config(). */
    char   *uri;			/* mongodb+srv:/*localhost:27017 */
    char   *dbname;			/* Database name */
    char   *collection;			/* Collection name */
    char   *query_filter;		/* db_common_expand() query template */
    char   *projection;			/* Advanced MongoDB projection */
    char   *result_attribute;		/* The key(s) to return the data for */
    char   *result_format;		/* db_common_expand() result_template */
    int     expansion_limit;		/* Result expansion limit */
    void   *ctx;			/* db_common handle */
} DICT_MONGODB;

/* Per-process initialization. */
static bool init_done = false;

/* itoa - int64_t to string */

static char *itoa(int64_t val)
{
    static char buf[21] = {0};
    int     ret;

    /*
     * XXX(Wietse) replaced custom code with standard library calls that
     * handle zero, and negative values.
     */
#define PRId64_FORMAT "%" PRId64

    ret = snprintf(buf, sizeof(buf), PRId64_FORMAT, val);
    if (ret < 0)
	msg_panic("itoa: output error for '%s'", PRId64_FORMAT);
    if (ret >= sizeof(buf))
	msg_panic("itoa: output for '%s' exceeds space %ld",
		  PRId64_FORMAT, sizeof(buf));
    return (buf);
}

/* mongodb_parse_config - parse mongodb configuration file */

static void mongodb_parse_config(DICT_MONGODB *dict_mongodb,
				         const char *mongodbcf)
{
    CFG_PARSER *p = dict_mongodb->parser;

    /*
     * Parse the configuration file.
     */
    dict_mongodb->uri = cfg_get_str(p, "uri", NULL, 1, 0);
    dict_mongodb->dbname = cfg_get_str(p, "dbname", NULL, 1, 0);
    dict_mongodb->collection = cfg_get_str(p, "collection", NULL, 1, 0);
    dict_mongodb->query_filter = cfg_get_str(p, "query_filter", NULL, 1, 0);

    /*
     * One of projection and result_attribute must be specified. That is
     * enforced in the caller.
     */
    dict_mongodb->projection = cfg_get_str(p, "projection", NULL, 0, 0);
    dict_mongodb->result_attribute
	= cfg_get_str(p, "result_attribute", NULL, 0, 0);
    dict_mongodb->result_format
	= cfg_get_str(dict_mongodb->parser, "result_format", "%s", 1, 0);
    dict_mongodb->expansion_limit
	= cfg_get_int(dict_mongodb->parser, "expansion_limit", 10, 0, 100);

    /*
     * db_common query parsing and domain pattern lookup.
     */
    dict_mongodb->ctx = 0;
    (void) db_common_parse(&dict_mongodb->dict, &dict_mongodb->ctx,
			   dict_mongodb->query_filter, 1);
    db_common_parse_domain(dict_mongodb->parser, dict_mongodb->ctx);
}

/* expand_value - expand lookup result value */

static bool expand_value(DICT_MONGODB *dict_mongodb, const char *p,
			         const char *lookup_name,
			         VSTRING *resultString,
			         int *expansion, const char *key)
{

    /*
     * If a lookup result cannot be processed due to an expansion limit
     * error, return a DICT_ERR_RETRY error code and a 'false' result value.
     * As documented for many dict_xxx() implementations, and expansion limit
     * error is considered a temporary error.
     */
    if (dict_mongodb->expansion_limit > 0
	&& ++(*expansion) > dict_mongodb->expansion_limit) {
	msg_warn("%s:%s: expansion limit exceeded for key: '%s'",
		 dict_mongodb->dict.type, dict_mongodb->dict.name, key);
	dict_mongodb->dict.error = DICT_ERR_RETRY;
	return (false);
    }

    /*
     * XXX(Wietse) Added the dict_mongodb_lookup() lookup_name argument,
     * because it selects code paths inside db_common_expand() that are
     * specifically for lookup results instead of lookup keys, including
     * %[SUD] substitution.
     */
    db_common_expand(dict_mongodb->ctx, dict_mongodb->result_format, p,
		     lookup_name, resultString, 0);
    return (true);
}

/* get_result_string - convert lookup result to string, or set dict.error */

static char *get_result_string(DICT_MONGODB *dict_mongodb,
			               VSTRING *resultString,
			               bson_iter_t *iter,
			               const char *lookup_name,
			               int *expansion,
			               const char *key)
{
    char   *p = NULL;
    bool    got_one_result = false;

    /*
     * If a lookup result cannot be processed due to an error, return a
     * non-zero error code and a NULL result value.
     */
    INIT_VSTR(resultString, BUFFER_SIZE);
    while (dict_mongodb->dict.error == DICT_ERR_NONE && bson_iter_next(iter)) {
	switch (bson_iter_type(iter)) {
	case BSON_TYPE_UTF8:
	    p = (char *) bson_iter_utf8(iter, NULL);
	    if (!bson_utf8_validate(p, strlen(p), true)) {
		msg_warn("%s:%s: invalid UTF-8 in lookup result '%s'",
		       dict_mongodb->dict.type, dict_mongodb->dict.name, p);
		dict_mongodb->dict.error = DICT_ERR_RETRY;
		break;
	    }
	    got_one_result |= expand_value(dict_mongodb, p, lookup_name,
					   resultString, expansion, key);
	    break;
	case BSON_TYPE_INT64:
	case BSON_TYPE_INT32:
	    p = itoa(bson_iter_as_int64(iter));
	    got_one_result |= expand_value(dict_mongodb, p, lookup_name,
					   resultString, expansion, key);
	    break;
	case BSON_TYPE_ARRAY:
	    ;					/* For pre-C23 Clang. */
	    const uint8_t *dataBuffer = NULL;
	    unsigned int len = 0;
	    bson_iter_t dataIter;
	    bson_t *data = NULL;

	    /*
	     * XXX(Wietse) are there any non-error cases, such as a valid but
	     * empty array, where bson_new_from_data() or bson_iter_init()
	     * would return null or false? If there are no such cases then we
	     * must handle null/false as an error.
	     */
	    bson_iter_array(iter, &len, &dataBuffer);
	    if ((data = bson_new_from_data(dataBuffer, len)) != 0
		&& bson_iter_init(&dataIter, data)) {
		VSTRING *iterResult = vstring_alloc(BUFFER_SIZE);

		if ((p = get_result_string(dict_mongodb, iterResult, &dataIter,
				       lookup_name, expansion, key)) != 0) {
		    vstring_sprintf_append(resultString, (got_one_result) ?
					   ",%s" : "%s", p);
		    got_one_result |= true;
		}
		vstring_free(iterResult);
	    }
	    bson_destroy(data);
	    break;
	default:
	    /* Unexpected field type. As documented, warn and ignore. */
	    msg_warn("%s:%s: failed to retrieve value of '%s', "
		     "Unknown result type %d.", dict_mongodb->dict.type,
		     dict_mongodb->dict.name, bson_iter_key(iter),
		     bson_iter_type(iter));
	    break;
	}
    }
    if (dict_mongodb->dict.error != DICT_ERR_NONE || !got_one_result)
	return (0);
    return (vstring_str(resultString));
}

/* dict_mongdb_quote - quote json string */

static void dict_mongdb_quote(DICT *dict, const char *name, VSTRING *result)
{
    /* quote_for_json_append() will resize the result buffer as needed. */
    (void) quote_for_json_append(result, name, -1);
}

/* dict_mongdb_append_result_attributes - projection builder */

static int dict_mongdb_append_result_attribute(bson_t * projection,
				               const char *result_attribute)
{
    char   *ra = mystrdup(result_attribute);
    char   *pp = ra;
    char   *cp;
    int     ok = 1;

    while (ok && (cp = mystrtok(&pp, CHARS_COMMA_SP)) != 0)
	ok = BSON_APPEND_INT32(projection, cp, 1);
    myfree(ra);
    return (ok);
}

/* dict_mongodb_lookup - find database entry using mongo query language */

static const char *dict_mongodb_lookup(DICT *dict, const char *name)
{
    DICT_MONGODB *dict_mongodb = (DICT_MONGODB *) dict;
    mongoc_collection_t *coll = NULL;
    mongoc_cursor_t *cursor = NULL;
    bson_iter_t iter;
    const bson_t *doc = NULL;
    bson_t *query = NULL;
    bson_t *options = NULL;
    bson_t *projection = NULL;
    bson_error_t error;
    char   *result = NULL;
    static VSTRING *queryString = NULL;
    static VSTRING *resultString = NULL;
    int     domain_rc;
    int     expansion = 0;

    dict_mongodb->dict.error = DICT_ERR_NONE;

    /*
     * If they specified a domain list for this map, then only search for
     * addresses in domains on the list. This can significantly reduce the
     * load on the database.
     */
    if ((domain_rc = db_common_check_domain(dict_mongodb->ctx, name)) == 0) {
	if (msg_verbose)
	    msg_info("%s:%s: skipping lookup of '%s': domain mismatch",
		     dict_mongodb->dict.type, dict_mongodb->dict.name, name);
	return (0);
    } else if (domain_rc < 0) {
	DICT_ERR_VAL_RETURN(dict, domain_rc, (char *) 0);
    }

    /*
     * Ugly macros to make error and non-error handling code more readable.
     * If code size is a concern, them an optimizing compiler can eliminate
     * dead code or duplicated code.
     */

    /* Set an error code, and return null. */
#define DICT_MONGODB_LOOKUP_ERR_RETURN(err) do { \
	dict_mongodb->dict.error = (err); \
	DICT_MONGODB_LOOKUP_RETURN((char *) 0); \
} while (0);

    /* Pass through any error, and return the specified value. */
#define DICT_MONGODB_LOOKUP_RETURN(val) do { \
	if (coll) mongoc_collection_destroy(coll); \
	if (cursor) mongoc_cursor_destroy(cursor); \
	if (query) bson_destroy(query); \
	if (options) bson_destroy(options); \
	if (projection) bson_destroy(projection); \
	return (val); \
    } while (0)

    coll = mongoc_client_get_collection(dict_mongodb->client,
					dict_mongodb->dbname,
					dict_mongodb->collection);
    if (!coll) {
	msg_warn("%s:%s: failed to get collection [%s] from [%s]",
		 dict_mongodb->dict.type, dict_mongodb->dict.name,
		 dict_mongodb->collection, dict_mongodb->dbname);
	DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
    }

    /*
     * Use the specified result projection, or craft one from the
     * result_attribute. Exclude the _id field from the result.
     */
    options = bson_new();
    if (dict_mongodb->projection) {
	projection = bson_new_from_json((uint8_t *) dict_mongodb->projection,
					-1, &error);
	if (!projection) {
	    msg_warn("%s:%s: failed to create a projection from '%s': %s",
		     dict_mongodb->dict.type, dict_mongodb->dict.name,
		     dict_mongodb->projection, error.message);
	    DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
	}
	if (!BSON_APPEND_INT32(projection, "_id", 0)
	    || !BSON_APPEND_DOCUMENT(options, "projection", projection)) {
	    msg_warn("%s:%s: failed to append a projection from '%s'",
		     dict_mongodb->dict.type, dict_mongodb->dict.name,
		     dict_mongodb->projection);
	    DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
	}
    } else if (dict_mongodb->result_attribute) {
	bson_t  res_attr;

	if (!BSON_APPEND_DOCUMENT_BEGIN(options, "projection", &res_attr)
	    || !BSON_APPEND_INT32(&res_attr, "_id", 0)
	    || !dict_mongdb_append_result_attribute(&res_attr,
					     dict_mongodb->result_attribute)
	    || !bson_append_document_end(options, &res_attr)) {
	    msg_warn("%s:%s: failed to append a projection from '%s'",
		     dict_mongodb->dict.type, dict_mongodb->dict.name,
		     dict_mongodb->result_attribute);
	    DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
	}
    } else {
	/* Can't happen. The configuration parser should reject this. */
	msg_panic("%s:%s: empty 'projection' and 'result_attribute'",
		  dict_mongodb->dict.type, dict_mongodb->dict.name);
    }

    /*
     * Expand filter template. This uses a quoting function to prevent
     * metacharacter injection with parts from a crafted email address.
     */
    INIT_VSTR(queryString, BUFFER_SIZE);
    if (!db_common_expand(dict_mongodb->ctx, dict_mongodb->query_filter,
			  name, 0, queryString, dict_mongdb_quote))
	/* Suppress the actual lookup if the expansion is empty. */
	DICT_MONGODB_LOOKUP_RETURN(0);

    /* Create the query from the expanded query template. */
    query = bson_new_from_json((uint8_t *) vstring_str(queryString),
			       -1, &error);
    if (!query) {
	msg_warn("%s:%s: failed to create a query from '%s': %s",
		 dict_mongodb->dict.type, dict_mongodb->dict.name,
		 vstring_str(queryString), error.message);
	DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
    }
    /* Run the query. */
    cursor = mongoc_collection_find_with_opts(coll, query, options, NULL);
    if (mongoc_cursor_error(cursor, &error)) {
	msg_warn("%s:%s: cursor error for '%s': %s",
		 dict_mongodb->dict.type, dict_mongodb->dict.name,
		 vstring_str(queryString), error.message);
	DICT_MONGODB_LOOKUP_ERR_RETURN(DICT_ERR_RETRY);
    }
    /* Convert the lookup result to C string. */
    INIT_VSTR(resultString, BUFFER_SIZE);
    while (mongoc_cursor_next(cursor, &doc)) {
	if (bson_iter_init(&iter, doc)) {
	    result = get_result_string(dict_mongodb, resultString, &iter,
				       name, &expansion, name);
	}
    }
    DICT_MONGODB_LOOKUP_RETURN(result);
}

/* dict_mongodb_close - close MongoDB database */

static void dict_mongodb_close(DICT *dict)
{
    DICT_MONGODB *dict_mongodb = (DICT_MONGODB *) dict;

    cfg_parser_free(dict_mongodb->parser);
    if (dict_mongodb->ctx) {
	db_common_free_ctx(dict_mongodb->ctx);
    }
    myfree(dict_mongodb->uri);
    myfree(dict_mongodb->dbname);
    myfree(dict_mongodb->collection);
    myfree(dict_mongodb->query_filter);

    if (dict_mongodb->result_attribute) {
	myfree(dict_mongodb->result_attribute);
    }
    if (dict_mongodb->result_format) {
	myfree(dict_mongodb->result_format);
    }
    if (dict_mongodb->projection) {
	myfree(dict_mongodb->projection);
    }
    if (dict_mongodb->client) {
	mongoc_client_destroy(dict_mongodb->client);
    }
    dict_free(dict);
}

/* dict_mongodb_open - open MongoDB database connection */

DICT   *dict_mongodb_open(const char *name, int open_flags, int dict_flags)
{
    DICT_MONGODB *dict_mongodb;
    CFG_PARSER *parser;
    mongoc_uri_t *uri = 0;
    bson_error_t error;

    /* Sanity checks. */
    if (open_flags != O_RDONLY) {
	return (dict_surrogate(DICT_TYPE_MONGODB, name, open_flags, dict_flags,
			       "%s:%s: map requires O_RDONLY access mode",
			       DICT_TYPE_MONGODB, name));
    }
    /* Open the configuration file. */
    if ((parser = cfg_parser_alloc(name)) == 0) {
	return (dict_surrogate(DICT_TYPE_MONGODB, name, open_flags, dict_flags,
			       "open %s: %m", name));
    }
    /* Create the dictionary object. */
    dict_mongodb = (DICT_MONGODB *) dict_alloc(DICT_TYPE_MONGODB, name,
					       sizeof(*dict_mongodb));
    dict_mongodb->dict.lookup = dict_mongodb_lookup;
    dict_mongodb->dict.close = dict_mongodb_close;
    dict_mongodb->dict.flags = dict_flags;
    dict_mongodb->parser = parser;
    dict_mongodb->dict.owner = cfg_get_owner(dict_mongodb->parser);
    dict_mongodb->client = NULL;

    /* Parse config. */
    mongodb_parse_config(dict_mongodb, name);
    if (!dict_mongodb->projection == !dict_mongodb->result_attribute) {
	dict_mongodb_close(&dict_mongodb->dict);
	return (dict_surrogate(DICT_TYPE_MONGODB, name, open_flags, dict_flags,
	 "%s:%s: specify exactly one of 'projection' or 'result_attribute'",
			       DICT_TYPE_MONGODB, name));
    }
    /* One-time initialization of libmongoc 's internals. */
    if (!init_done) {
	mongoc_init();
	init_done = true;
    }
#define DICT_MONGODB_OPEN_ERR_RETURN(d) do { \
	DICT   *_d = (d); \
	if (uri) mongoc_uri_destroy(uri); \
	dict_mongodb_close(&dict_mongodb->dict); \
	return (_d); \
    } while (0);

    uri = mongoc_uri_new_with_error(dict_mongodb->uri, &error);
    if (!uri)
	DICT_MONGODB_OPEN_ERR_RETURN(dict_surrogate(DICT_TYPE_MONGODB, name,
						    open_flags, dict_flags,
				      "%s:%s: failed to parse URI '%s': %s",
						    DICT_TYPE_MONGODB, name,
					 dict_mongodb->uri, error.message));

    dict_mongodb->client = mongoc_client_new_from_uri_with_error(uri, &error);
    if (!dict_mongodb->client)
	DICT_MONGODB_OPEN_ERR_RETURN(dict_surrogate(DICT_TYPE_MONGODB, name,
						    open_flags, dict_flags,
			      "%s:%s: failed to create client for '%s': %s",
						    DICT_TYPE_MONGODB, name,
						    dict_mongodb->uri,
						    error.message));

    mongoc_uri_destroy(uri);
    mongoc_client_set_error_api(dict_mongodb->client, MONGOC_ERROR_API_VERSION_2);
    return (&dict_mongodb->dict);
}

#endif
