/* ====================================================================
 * Copyright 2003 Sönke Tesch. All rights reserved.
 * Version 20031023
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in
 *    the documentation and/or other materials provided with the
 *    distribution.
 *
 * 3. The end-user documentation included with the redistribution,
 *    if any, must include the following acknowledgment:
 *       "This product includes software developed by Sönke Tesch
 *        (http://bitbrook.de/)."
 *    Alternately, this acknowledgment may appear in the software itself,
 *    if and wherever such third-party acknowledgments normally appear.
 *
 * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED
 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED.  IN NO EVENT SHALL THE APACHE SOFTWARE FOUNDATION OR
 * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
 * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
 * OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 * ====================================================================
 *
 * This module would not have been possible without taking a very close
 * look at source code originally written by or for the Apache Software
 * Foundation (see http://www.apache.org). That's why a few dozen lines
 * might look familiar :-]
 */

/*
 * mod_log_mysql, 23. October 2003
 *
 * Logging into an SQL database. Needs the modular mod_log_config, also
 * available from http://bitbrook.de/software/mod_log_mysql/ .
 *
 * Changes since 20031005:
 * - works without threads, i.e. if APR_HAS_THREADS == 0
 * --- st */

/*
 * NOTE: There's a bug in apr_reslist.c <= 0.9.4: 
 * apr_reslist_acquire() always returns success, even if no resource
 * was created. This module would therefore crash due to the bogus
 * resource returned whenever the database server is not available
 * upon an _initial_ connection attempt.
 *
 * A work-around in this module would mean creating zombie resources,
 * so I chose to do it the right way in here and instead supply a patch
 * for apr_reslist.c to fix this bug.
 * The patch should have come with this file or is available on the Net
 * at http://bitbrook.de/software/mod_log_mysql/apr_reslist.diff .
 *
 * This bug should be fixed in Apache 2.0.48 (not released yet).
 */
 
#include "apr_strings.h"
#include "apr_lib.h"
#include "apr_hash.h"
#include "apr_optional.h"
#include "apr_reslist.h"

#define APR_WANT_STRFUNC
#include "apr_want.h"

#include "ap_config.h"
#include "mod_log_config.h"
#include "httpd.h"
#include "http_config.h"
#include "http_log.h"
#include "http_protocol.h"
#include "util_time.h"

#include <mysql.h>
#include <errmsg.h>

#if APR_HAVE_UNISTD_H
#include <unistd.h>
#endif
#ifdef HAVE_LIMITS_H
#include <limits.h>
#endif

/* Some default database connection parameters: */

#define DBCONNECTION_INITIAL 0
#define DBCONNECTION_SOFT 1
#define DBCONNECTION_HARD 256
#define DBCONNECTION_TIMEOUT 36000
#define FALLBACK_RETRY_DELAY 30

/*
 * This module uses a connection pool. In a threaded Apache, connection(s)
 * to the same database server (actually: log target) are shared among
 * childs and additional connections will be opened as needed.
 *
 * DBCONNECTION_INITIAL is the number of initial connections per pool, 
 * _HARD is the absolute maximum number of connections (threads will
 * have to wait if this limit is reached), _TIMEOUT is the duration an
 * unused connection is kept before removing it, _SOFT is the minimum
 * number of connections to keep in any case, regardless of _TIMEOUT.
 *
 * 1. DBCONNECTION_INITIAL is 0 to avoid creating MySQL connections within
 * the master process, which are a) not needed by the master and b) I'm
 * not sure of if they can simply be copied to the childs. 
 * As soon as needed, a child will open its own connection and keep at 
 * least _SOFT connections open until it dies anyway.
 * Unfortunatly no initial connections means you will not notice a broken
 * log URI or an unreachable MySQL server until you actually start logging.
 * Does this need to be fixed? xxx
 * 2. There is no need to raise DBCONNECTION_SOFT (and _INITIAL) above 1 
 * if you are not using a threaded Apache. Pools only work per process.
 * 3. DBCONNECTION_TIMEOUT interferes with MySQL's own connection timeout.
 * Use a "mod_log_mysql" paragraph in your my.cnf to set this limit (and
 * other stuff) withing the MySQL configuration (that's why it is set to
 * ten hours up there).
 *
 * FALLBACK_RETRY_DELAY is the time between two attempts to open the
 * database connection again after it failed.
 */

module AP_MODULE_DECLARE_DATA log_mysql_module;

/* module configuration block: */

typedef struct {
    const char *fallbackDir;
    int reconnect_timeout;
} mysql_log_config;

/* A data block to manage a single log target: */
typedef struct {
#if APR_HAS_THREADS
    apr_reslist_t *dbs; /* connection pool */
#else
    MYSQL *db;          /* no threads, no pool */
#endif
    const char *uri;    /* the complete log uri.. */
    char *host;         /* ..and its parts        */
    char *user;
    char *passwd;
    int port;
    char *socket;
    char *database;
    
    char *fn;       /* fallback file name */
    apr_file_t *fh; /*    "     file handle */
    apr_time_t ft;  /*    "     start time, also indicates fallback active if > 0 */
    
    apr_pool_t *p;  /* dbs above uses this pool to create database connections */
} mysql_log;

/* The list which keeps all known log targets, i.e. mysql_log items */
static apr_hash_t *db_hash;


/* open a new database connection, called automatically by mysql_log.dbs */
static apr_status_t open_db_connection(void **resource, void *param, apr_pool_t *p)
{
  mysql_log *l = param;
  MYSQL *db;
    
  db = apr_palloc(p, sizeof(MYSQL));

  mysql_init(db);
  mysql_options(db, MYSQL_READ_DEFAULT_GROUP, "mod_log_mysql");

  if (! mysql_real_connect(db, l->host, l->user, l->passwd, l->database, l->port, l->socket, 0)) {
      ap_log_perror(APLOG_MARK, APLOG_CRIT, 0, p, "log database %s: %s", l->uri, mysql_error(db));
      *resource = NULL;
      return !APR_SUCCESS;
  }
  else {
      *resource = db;
      return APR_SUCCESS; 
  }
}

/* close a database connection, called automatically by mysql_log.dbs, too */
static apr_status_t close_db_connection(void *resource, void *param, apr_pool_t *p)
{
  if (resource) {
      mysql_close((MYSQL *)resource);
  }
}

/* setup a new log target, called from mod_log_config */
static void *mysql_log_setup(apr_pool_t *p, server_rec *s, 
                             const char* name)
{
    mysql_log *l;
    char *uri;
    char *c = NULL;
    apr_status_t as;
    mysql_log_config *conf = ap_get_module_config(s->module_config,
                                                  &log_mysql_module);

    if (! (l = apr_hash_get(db_hash, name, APR_HASH_KEY_STRING))) {
        l = apr_palloc(p, sizeof(mysql_log));
        l->p = p; /* for use with sql_fallback */

#if APR_HAS_THREADS
        /* no initial connections created here in order to avoid opening things in the root process */
        if (apr_reslist_create(&l->dbs, DBCONNECTION_INITIAL, DBCONNECTION_SOFT, DBCONNECTION_HARD, DBCONNECTION_TIMEOUT, open_db_connection, close_db_connection, l, p) != APR_SUCCESS) {
            return NULL;
        }
#else
        l->db = NULL;
#endif
        l->uri = apr_pstrdup(p, name); /* keep our full name */
        uri = apr_pstrdup(p,name);     /* duplicate for l.user, .passwd, etc. */

        l->user = NULL;   /* initialize connection data */
        l->passwd = NULL;
        l->host = NULL;
        l->port = 0;
        l->socket = 0;

        if (l->database = ap_strrchr(uri,'/')) { /* got /database or /sock/et/ */
            if (*(l->database + 1) == '\0') {    /* found slash is last character in name, i.e. part of socket path */
                c = l->database; /* keep position for socket below */ 
                l->database = NULL;
           }
            else {
                *l->database = '\0';
                l->database++;
            }
        }
        /* xxx needs better handling of invalid uris, especially empty fields */
        if (l->database != uri + 1) { /* name is not "/database", look for host and user */
            l->host = ap_strchr(uri,'@');
            if (l->host ) { /* got @host, @host:sock/et/ or @host:port */
                *l->host = '\0';
                l->host++;
                l->socket = ap_strchr(l->host,':');
                if ((*l->host == ':') || (*l->host == '\0')) /* empty host */
                    l->host = NULL;
                if (l->socket) { /* got socket or port. end is determined by 'database' above */
                    *l->socket = '\0';
                    l->socket++;
                    if (*l->socket == '\0') { /* empty socket */
                        l->socket = NULL;
                    }
                    else {
                        if (c) /* set eos marker at beginning of database */
                            *c = '\0';
                        l->port = apr_strtoi64(l->socket,&c,10);
                        if (*c == '\0') {
                            l->socket = NULL;
                        }
                        else {
                            l->port = 0;
                            l->socket = ap_server_root_relative(p, l->socket);
                        }
                    }
                }
            }
            if (l->host != uri + 1) { /* name is not "@host..", read user!passwd or user */
                l->user = uri;        /* got user or user!passwd. end is determined by 'host' or 'database' above */
                if (l->passwd = ap_strchr(l->user, '!')) { /* got !passwd */
                    *l->passwd = '\0';
                    l->passwd++;
                }
            }
        }
        
        if (l->passwd) { /* hide password */
            c = ap_strchr(l->uri, '!');
            c++;
            while ((*c != '@') && (*c != '/') && (*c !='\0')) {
                *c = 'X';
                c++;
            }
        }
    
        l->fh = NULL;
        l->ft = 0;
        if (! conf->fallbackDir) {
            l->fn = NULL;
        }
        else {
            l->fn = apr_pstrdup(p, l->uri);
            c = l->fn;
            while (*c != '\0') { /* make uri filesystem-safe, might be a bit..dumb */
                if (! apr_isalnum(*c)) {
                     *c = '_';
                }
                c++;
            }
    
            if (as = apr_filepath_merge(&l->fn, conf->fallbackDir, l->fn, APR_FILEPATH_SECUREROOT, p) != APR_SUCCESS) {
                ap_log_error(APLOG_MARK, APLOG_ERR, as, s, "cannot merge fallback dir path and fallback filename");
                return NULL;
            }
            l->fn = ap_server_root_relative(p, l->fn);
        }

        apr_hash_set(db_hash, name, APR_HASH_KEY_STRING, l);
    }
    return l;
}

/* write to file if database connection is down, called from mysql_log_write */
static void sql_fallback(request_rec *r, mysql_log *l, char *s)
{
    apr_status_t as;

    if ((l->ft) && (! l->fh)) { /* could not open fallback file earlier */
        return;
    }

    if (! l->ft) { /* called the first time: activate timer, output a warning */
        l->ft = r->request_time;
        if (! l->fn) { /* no fallback directory */
            ap_log_rerror(APLOG_MARK, APLOG_ALERT, 0, r, "log database server gone, no fallback file specified, will loose log data!");
            return;
        }
        ap_log_rerror(APLOG_MARK, APLOG_WARNING, 0, r, "log database server gone. trying fallback %s.", l->fn);
    }
    
    
    if (! l->fh) {
        as = apr_file_open(&l->fh, l->fn, APR_WRITE | APR_CREATE | APR_APPEND | APR_XTHREAD, APR_OS_DEFAULT, l->p);
        if (as != APR_SUCCESS) {
            ap_log_rerror(APLOG_MARK, APLOG_ALERT, as, r, "cannot open fallback file %s, will loose log data!", l->fn);
            return;
        }
    }
    apr_file_printf(l->fh, "%s" APR_EOL_STR, s);
}

/* log a request */
static apr_status_t mysql_log_write(request_rec *r,
                                    void *handle, apr_array_header_t *data)

{
    char *str;
    char *s;
    char **strs;
    int *strl;
    int i,j,a;
    int len = 0;
    apr_status_t rv;
    const ap_log_ehandler_data *d;
    apr_time_exp_t xt;
    apr_size_t retcode;
    char tstr[15];
    MYSQL *db;
    mysql_log *l = (mysql_log*)handle;
    mysql_log_config *conf;

#if APR_HAS_THREADS
    if (l->ft) { /* connection failed in an earlier request */
        conf = ap_get_module_config(r->server->module_config,
                                                  &log_mysql_module);
        if (l->ft > r->request_time - apr_time_from_sec(conf->reconnect_timeout)) {
            if (apr_reslist_acquire(l->dbs, (void *)&db) != APR_SUCCESS) {
                db = NULL;
            }
            l->ft = r->request_time; /* reset connection retry timer */
        }
    } 
    else if (apr_reslist_acquire(l->dbs, (void *)&db) != APR_SUCCESS) {
        db = NULL;
    }
#else
    if ((! l->db) && (open_db_connection((void*)&l->db, l, l->p) != APR_SUCCESS)) {
        db = NULL;
    }
    else {
        db = l->db;
    }
#endif

    strs = apr_palloc(r->pool, sizeof(char *) * (data->nelts));
    strl = apr_palloc(r->pool, sizeof(int) * (data->nelts));
    for (i = 0; i < data->nelts; ++i) {
        d=&(((ap_log_ehandler_data*)(data->elts))[i]);
        if ((d) && (d->data)) {
            switch (d->type) {
            case AP_LOG_EHANDLER_RETURN_OLDSTYLE:
            case AP_LOG_EHANDLER_RETURN_STRING:
                j = strlen(d->data);
                strs[i] = apr_palloc(r->pool, 2*j+3);
                if (d->arg && (apr_strnatcasecmp(d->arg,"mysqlname")==0 )) {
                    for (a = 0, s = d->data; *s != '\0'; s++) {
                        if (isalnum(*s)) {
                            strs[i][a] = *s;
                            a++;
                        }
                    }
                    strl[i] = a;
                }
                else {
                    strs[i][0] = '"';
                    if (db) {
                        strl[i] = mysql_real_escape_string(db, strs[i] + 1, d->data, j) + 2;
                    }
                    else { /* database failed */
                        strl[i] = mysql_escape_string(strs[i] + 1, d->data, j) + 2;
                    }
                    strs[i][strl[i] - 1] = '"';
                }
                strs[i][strl[i]] = '\0';
                break;
            case AP_LOG_EHANDLER_RETURN_CONST:
                strs[i] = d->data;
                strl[i] = strlen(strs[i]);
                break;
            case AP_LOG_EHANDLER_RETURN_NUMBER:
                strs[i] = apr_psprintf(r->pool,"%" AP_LOG_NUMBER_T_FMT,*(ap_log_number_t *) d->data); // xxx
                strl[i] = strlen(strs[i]);
                break;
            case AP_LOG_EHANDLER_RETURN_UNUMBER:
                strs[i] = apr_psprintf(r->pool,"%" AP_LOG_UNUMBER_T_FMT,*(ap_log_unumber_t *) d->data); // xxx
                strl[i] = strlen(strs[i]);
                break;
            case AP_LOG_EHANDLER_RETURN_DATETIME:
                ap_explode_recent_localtime(&xt, *(apr_time_t*)d->data);
                /* if (d->arg && (*(char *)(d->arg))) { xxx */
                apr_strftime(tstr, &retcode, 15, "%Y%m%d%H%M%S", &xt);
                strs[i] = apr_pstrdup(r->pool, tstr);
                strl[i] = strlen(strs[i]);
                break;
            }
        }
        else {
            strs[i] = "NULL";
            strl[i] = 4;
        }
        len += strl[i];
    }

    str = apr_palloc(r->pool, len + 1);
    for (i = 0, s = str; i < data->nelts; ++i) {
        memcpy(s, strs[i], strl[i]);
        s += strl[i];
    }
    *s = '\0';

    if (!db) {
        sql_fallback(r, l, str);
    }
    else if (mysql_real_query(db, str, len)) {
        switch (mysql_errno(db)) {
        case CR_SERVER_LOST:
        case CR_SERVER_GONE_ERROR:
        case CR_CONNECTION_ERROR:
            sql_fallback(r, l, str);
            break;
        default:
            ap_log_rerror(APLOG_MARK, APLOG_CRIT, 0, r, "\"%s\": %s", str, mysql_error(db));
        }
    }
    else if (l->ft) {
        ap_log_rerror(APLOG_MARK, APLOG_NOTICE, 0, r, "resuming logging to database %s", l->uri);
        l->ft = 0; /* connection open again, unset connection retry timer */
    }

#if APR_HAS_THREADS
    if (db) {
        apr_reslist_release(l->dbs, db);
    }
#endif

    return OK;
}


/*****************************************************************
 *
 * Module glue...
 */

static void *make_log_mysql_config(apr_pool_t *p, server_rec *s)
{
    mysql_log_config *conf;

    conf = (mysql_log_config *)apr_palloc(p, sizeof(mysql_log_config));

    conf->fallbackDir = NULL;
    conf->reconnect_timeout = FALLBACK_RETRY_DELAY;

    return conf;
}

static const char *logmysql_fallback(cmd_parms *cmd, void *dummy, const char *fn, const char *timeout)
{
    mysql_log_config *conf = ap_get_module_config(cmd->server->module_config,
                                                  &log_mysql_module);
    conf->fallbackDir = fn;
    if (timeout) {
        conf->reconnect_timeout = apr_atoi64(timeout);
    }

    return OK;
}

static const command_rec log_mysql_cmds[] =
{
AP_INIT_TAKE12("LogMySQLFallback", logmysql_fallback, NULL, RSRC_CONF,
    "directory to store SQL commands in if database connection is down and connection retry delay"),
    {NULL}
};

static int log_mysql_pre_config(apr_pool_t *p, apr_pool_t *plog, apr_pool_t *ptemp)
{
    static APR_OPTIONAL_FN_TYPE(ap_register_log_ewriter) *register_writer;

    register_writer = APR_RETRIEVE_OPTIONAL_FN(ap_register_log_ewriter);
    if (register_writer) {
        register_writer(p, "mysql", mysql_log_setup, mysql_log_write, NULL, NULL);
    }

    return OK;
}

static apr_status_t log_mysql_child_exit(void *data)
{
    apr_pool_t *p = data;
    apr_hash_index_t *i;
    mysql_log *l;

    for (i = apr_hash_first(p, db_hash); i; i = apr_hash_next(i)) {
        apr_hash_this(i, NULL, NULL, (void*) &l);
#if APR_HAS_THREADS
        apr_reslist_destroy(l->dbs);
#else
        close_db_connection(l->db, l, l->p);
#endif
    }
}

static void log_mysql_child_init(apr_pool_t *p, server_rec *s)
{
    apr_pool_cleanup_register(p, p, log_mysql_child_exit, log_mysql_child_exit);
}

static void register_hooks(apr_pool_t *p)
{
    static const char *pre[] = { "mod_log_config.c", NULL }; /* register our log writer before mod_log_config starts */
    db_hash = apr_hash_make(p);
    ap_hook_pre_config(log_mysql_pre_config, pre, NULL, APR_HOOK_REALLY_FIRST);
    ap_hook_child_init(log_mysql_child_init, NULL, NULL, APR_HOOK_MIDDLE);
}

module AP_MODULE_DECLARE_DATA log_mysql_module =
{
    STANDARD20_MODULE_STUFF,
    NULL,                       /* create per-dir config */
    NULL,                       /* merge per-dir config */
    make_log_mysql_config,      /* server config */
    NULL,                       /* merge server config */
    log_mysql_cmds,             /* command apr_table_t */
    register_hooks              /* register hooks */
};
