#!/bin/bash
# Copyright (c) 2018-2021:
#   Darren 'Tadgy' Austin <darren (at) afterdark.org.uk>
# Licensed under the terms of the GNU General Public License version 3.
#
#........1.........2.........3.........4.........5.........6.........7.........8.........9.........0.........1..:......2.........3.........4.........5.........:


# Script details.
NAME="${0##*/}"
VERSION="0.4.0"


# Functions.
check_leading_dirs() {
  # $1		The virtual host being processed.
  # $2		The path of which to validate the leading directories.

  [[ -z "$1" || -z "$2" ]] && return 1

  if ! is_dir "$(remove_expansions "$2")"; then
    (( FLAGS[${1}_template-prefix] == 0 )) && {
      syslog "warn" "prefix directories of template do not exist: $(remove_expansions "$2")"
      FLAGS[${1}_template-prefix]=1
    }
    return 1
  else
    (( FLAGS[${1}_template-prefix] == 1 )) && {
      syslog "info" "prefix directories of template reappeared: $(remove_expansions "$2")"
      FLAGS[${1}_template-prefix]=0
    }
  fi
  return 0
}

close_fd() {
  # $1		The site of which to close the file descriptor.

  [[ -z "$1" ]] && return 1

  # shellcheck disable=SC1083
  { exec {FDS["$1"]}>&-; } 2>/dev/null || syslog "warn" "failed to close FD ${FDS[$1]} for $1"
  unset "FDS[$1]" "FLAGS[${1}_template-prefix]" "FLAGS[${1}_make-dir-fail]" "FLAGS[${1}_fix-link]"
}

create_missing_dirs() {
  # $1		The virtual host being processed.
  # $2		The directory to insure exists.

  [[ -z "$1" || -z "$2" ]] && return 1

  if ! make_dir "$2"; then
    (( FLAGS[${1}_make-dir-fail] == 0 )) && {
      syslog "warn" "error creating log file's directory: $2"
      FLAGS[${1}_make-dir-fail]=1
    }
    return 1
  else
    (( FLAGS[${1}_make-dir-fail] == 1 )) && {
      syslog "info" "created log file's directory: $2"
      FLAGS[${1}_make-dir-fail]=0
    }
  fi
  return 0
}

die() {
  # $1		The text of the error message to display on stderr.

  (( DEBUG == 1 )) && printf "%s: %s: %s\\n" "$(date "+%Y%m%d %H%M%S.%N")" "die" "$1" >>"$DEBUG_FILE"
  printf "%s: %s\\n" "$NAME" "$1" >&2
  exit 1
}

display_help() {
  #     |........1.........2.........3.........4.........5.........6.........7.........8
  cat <<-EOF
	Usage: $NAME [options] <basedir> <template>
	Process input (possibly including an httpd VirtualHost site identifier) from
	stdin or a pipe/FIFO and write a log line to a log file based upon the <basedir>
	and <template>.

	Options (all of which are optional):
	  -ca <arg>	Set the compression command arguments.  Default: ${COMPRESSOR_ARGS[@]}.
	                Quotes are required if more than one <arg> is supplied.
	  -cc <util>	Set the compression command to use.  Default: $COMPRESSOR.
	  -f		Request flushing of the log file to disk after every write.
	                This may significantly reduce performance and result in a lot of
	                disk writes.  Best to let the kernel do appropriate buffering.
	  -g <group>	Set name of the group to run with.  With this option, as soon as
	                $NAME starts it will re-exec itself to run as this group.
	                Log files created by $NAME will be owned by this group.  The
	                default is to run as a primary group of any user given by '-u'
	                or the user that executed $NAME, which is usually root.
	                When combind with '-u', the group $NAME will run under is no
	                longer the primary group of that user but will be this group.
	                This option is only available to root.
	  -h		Display this help.
	  -i <pipe>	Read input from the pipe/FIFO at <pipe>, rather than stdin.
	                If the pipe/FIFO does not exist, it will be created.  Use '-o'
	                to set the ownership of the pipe/FIFO.  The pipe/FIFO is created
	                before any user or group switching is performed.
	  -j <jobs>	Maximum number of compression jobs to have active at once.
	                Default: $MAXJOBS.  Don't set this too high.
	  -l <link>	Create a symlink named <link> to the currently active log file.
	                The <link> is created relative to <basedir>.  In normal mode,
	                the link name may include the same '{}' sequence and %-escaped
	                formatting as the <template> (see below).  In raw mode (-r), the
	                '{}' is not allowed, but % escape sequences can still be used.
	                WARNING: The (expanded) location of this link will be WIPED OUT!
	  -md <umask>	Set the umask used when creating directories.  Default: $DIR_UMASK.
	                Useful umasks are: 077, 066, 026 and 022.
	  -mf <umask>	Set the umask used when creating files.  Default: $FILE_UMASK.
	                Useful umasks are: 066 and 022.
	  -mp <umask>	Set the umask used when creating the pipe.  Default: $PIPE_UMASK.
	                Useful umasks are: 066 and 006.
	  -o <owner>	Set the owner of the pipe/FIFO automatically created if none
	                already exists, and '-i' is used.  The <owner> should be in the
	                format [user]:[group], where [user] or [group] is optional, but
	                not both.  The ownership is changed before any user or group
	                switching is performed.  This option is only available to root.
	  -p		Make all parents.  Normally, all directories up to - but not
	                including - the first directory with non-escaped %-format
	                strings of the <template> (see below) must already exist for the
	                log lines to be written to the file.  With this option, the
	                parent directories of the logfile will be created automatically.
	                WARNING: This option can be unsafe with certain <template>
	                formats - it can result in creation of arbitrary directories
	                based on unclean input from outside sources.
	  -r		Raw logging mode.  In this mode, no processing of the log line
	                for an httpd VirtualHost site identifier is performed - log
	                lines are written verbatim to the log filename constructed from
	                <basedir> and <template>.
	  -s <facility>	Set the syslog facility to be used for logging.  Default: $SYSLOG_FACILITY.
	  -u <user>	Set name of the user to run with.  With this option, as soon as
	                $NAME starts it will re-exec itself to run as this user.
	                Log files created by $NAME will be owned by this user and
	                its primary group.  The default is to run as the user that
	                executed $NAME, which is usually root.  This option is
	                only available to root.
	  -v		Display version and copyright information.
	  -z		Enable compression of the old log files.
	  --		Cease option processing and begin argument parsing.
	  Option processing ceases with the first non-option argument or --.

	Arguments (all of which are mandatory):
	  <basedir>	The base directory of where to write the log files.  The base
	                directory must exist.
	  <template>	The filename template.  When in normal mode, the template must
	                include at least one occurrance of '{}', which is replaced with
	                the site name from the VirtualHost identifier.  In raw mode
	                (-r), the '{}' should not be included in the template.  The
	                template may also include any %-prefixed format strings
	                recognised by the strftime(3) function.  See below for examples.
	                The template can not start with a / - it is relative to the
	                <basedir>.  Unless the '-p' option is used, all directories up
	                to - but not including - the first directory with non-escaped
	                %-format strings of the <template> must already exist for the
	                log lines to be written to the file.

	Examples:
	  When used with the httpd CustomLog directive, using %v as the first log format
	  string:
	    "|$NAME '/path/to/logsdir' '{}/logs/access-log-%Y-%m'"
	      Where the httpd VirtualHost identifier is 'example.com', would write logs
	      (with the site identifier stripped) to the filename:
	        /path/to/logsdir/example.com/logs/access-log-<year>-<month>
	    "|$NAME '/path/to/logsdir' '{}/logs/()-access-log-%Y-%m'"
	      Where the httpd VirtualHost identifier is 'example.com', would write logs
	      (with the site identifier steipped) to the filename:
	        /path/to/logsdir/example.com/logs/example.com-access-log-<year>-<month>
	  When used with the httpd ErrorLog directive (both examples are equilivent):
	    "|$NAME -r '/path/to/logsdir' 'logs/error-log-%Y-%m'"
	      Would write raw log lines to the filename:
	        /path/to/logsdir/logs/error-log-<year>-<month>
	    "|$NAME -r '/path/to/logsdir/logs' 'error-log-%Y-%m'"
	      Equilivant to the above; would write raw log lines to the filename:
	        /path/to/logsdir/logs/error-log-<year>-<month>
EOF
}

display_version() {
  #     |........1.........2.........3.........4.........5.........6.........7.........8
  cat <<-EOF
	$NAME v$VERSION.
	Copyright (c) 2018-2020 Darren 'Tadgy' Austin <darren (at) afterdark.org.uk>.
	Licensed under the terms of the GNU GPL v3 <http://gnu.org/licenses/gpl.html>.
	This program is free software; you can modify or redistribute it in accordence
	with the GNU GPL.  However, it comes with ABSOLUTELY NO WARRANTY; not even the
	implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
EOF
}

exit_handler() {
  (( INPUTFD != 0 )) && { exec {INPUTFD}>&-; } 2>/dev/null

  (( FLAGS[created-fifo] == 1 )) && {
    rm -f "$INPUT" 2>/dev/null || syslog "warn" "failed to remove pipe/fifo: $INPUT"
  }
}

is_dir() {
  # $1		The path to verify is a directory.

  [[ -z "$1" ]] && return 1

  [[ ! -d "$1" ]] && return 1
  return 0
}

make_dir() {
  # $1		The directory to create.

  [[ -z "$1" ]] && return 1

  if [[ ! -e "$1" ]]; then
    umask "$DIR_UMASK"
    mkdir -p "$1" 2>/dev/null || return 1
  else
    is_dir "$1" || return 1
  fi
  return 0
}

open_fd() {
  # $1		The site/vhost identifier in the array.
  # $2		The log file path to open.

  [[ -z "$1" || -z "$2" ]] && return 1
  umask "$FILE_UMASK"
  # shellcheck disable=SC1083
  if ! { exec {FDS["$1"]}>>"$2"; } 2>/dev/null; then
    (( FLAGS[${1}_open-fd-fail] == 0 )) && {
      syslog "error" "failed to open log file for writing: $2"
      FLAGS[${1}_open-fd-fail]=1
    }
    return 1
  else
    (( FLAGS[${1}_open-fd-fail] == 1 )) && {
      syslog "info" "opened log file for writing: $2"
      FLAGS[${1}_open-fd-fail]=0
    }
  fi
  return 0
}

remove_expansions() {
  # This function takes a template path as input and will output a path with elements
  # starting with the first (non-escaped) %-sequence to the end of the path, removed.
  # That is, it will return the path with the variable parts removed.
  # $1		The path to parse.

  local IFS='/' ITEMS INDEX

  read -r -a ITEMS <<<"${1//+(\/)/\/}"

  for INDEX in "${!ITEMS[@]}"; do
    # Thanks to Marc Eberhard for helping with the regex that I couldn't quite get right.
    if [[ "${ITEMS[INDEX]}" =~ ^((%%)*[^%]*)*[%]?$ ]]; then
      printf "%s" "${ITEMS[INDEX]}"
      [[ -n "${ITEMS[INDEX+1]}" ]] && [[ "${ITEMS[INDEX+1]}" =~ ^((%%)*[^%]*)*[%]?$ ]] && printf "%s" "/"
    else
      break
    fi
  done
}

sigchld_handler() {
  local JOB

  for JOB in "${!JOBS[@]}"; do
    [[ "${JOBS[$JOB]}" ]] && {
      ! kill -0 "${JOBS[$JOB]}" >/dev/null 2>&1 && {
        wait "${JOBS[$JOB]}"
        unset "JOBS[$JOB]"
        (( RUNNING_JOBS-- ))
      }
    }
  done
  start_compression_jobs
  (( RUNNING_JOBS == 0 )) && set +bm
}

sighup_handler() {
  local SITE

  syslog "info" "closing all file descriptors"
  for SITE in "${!FDS[@]}"; do
    close_fd "$SITE"
  done
}

sigterm_handler() {
  local SITE

  for SITE in "${!FDS[@]}"; do
    close_fd "$SITE"
  done
  disown -a
  (( FLAGS[created-fifo] == 1 )) && {
    rm -f "$INPUT" 2>/dev/null || syslog "warn" "failed to remove pipe/fifo: $INPUT"
  }
  exit 0
}

start_compression_jobs() {
  local JOB

  while (( RUNNING_JOBS < MAXJOBS )); do
    for JOB in "${!JOBS[@]}"; do
      [[ ! "${JOBS[$JOB]}" ]] && {
        set -bm
        "$COMPRESSOR" "${COMPRESSOR_ARGS[@]}" "$JOB" >/dev/null 2>&1 &
        JOBS[$JOB]="$!"
        (( RUNNING_JOBS++ ))
        continue 2
      }
    done
    break
  done
}

syslog() {
  # $1		The syslog level at which to log the message.
  # $2		The text of the message to log.

  [[ -z "$1" || -z "$2" ]] && return 1

  (( DEBUG == 1 )) && printf "%s: %s: %s: %s\\n" "$(date "+%Y%m%d %H%M%S.%N")" "syslog" "$1" "$2" >>"$DEBUG_FILE"
  logger --id="$$" -p "$SYSLOG_FACILITY.$1" -t "$NAME" "$1: $2" 2>/dev/null
}

toggle_debug() {
  (( DEBUG == 0 )) && DEBUG="1" && return
  (( DEBUG == 1 )) && DEBUG="0" && return
}


# Extended globs are required.
shopt -s extglob

# Some variables.
# The array of file descriptors corresponding to each path.
declare -A FDS
# The array of jobs needing to be compressed.
declare -A JOBS
# The array of flags.
declare -A FLAGS
# The number of compression jobs currently active.
RUNNING_JOBS=0
# The original arguments to the script.
ORIG_ARGS=()

# Some detaults.
COMPRESSOR_ARGS=( "-9" )
COMPRESSOR="gzip"		# Use gzip by default as log processing utils can often natively read gzipped files.
DEBUG="0"
DEBUG_FILE="/tmp/lumberjack.$$.debug.$RANDOM"
INPUT=""
INPUTFD="0"
MAXJOBS="4"
LINKFILE=""
DIR_UMASK="022"
FILE_UMASK="022"
PIPE_UMASK="066"
PIPE_OWNER=""
SYSLOG_FACILITY="user"
RUNAS_USER=""
RUNAS_GROUP=""
FLAGS=([flush]=0 [raw]=0 [compress]=0 [make-parents]=0 [created-fifo]=0 [timed-out]=0 [basedir-vanished]=0 [basedir-notdir]=0)

# trap signals.
trap 'sigchld_handler' SIGCHLD
trap 'sighup_handler' SIGHUP
trap 'syslog "info" "received SIGUSR1 ping request"' SIGUSR1
trap 'toggle_debug' SIGUSR2
trap 'sigterm_handler' SIGTERM
trap 'exit_handler' EXIT

# Parse command line options.
while :; do
  case "$1" in
    -ca)
      # Set the compression command arguments.
      [[ ! "$2" ]] && die "missing argument to -ca"
      read -r -a COMPRESSOR_ARGS <<<"$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -cc)
      # Set the compression command to use.
      [[ ! "$2" ]] && die "missing argument to -cc"
      "$2" --help >/dev/null 2>&1 || die "invalid compressor command: $2"
      COMPRESSOR="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -f)
      # Flush files after every write.
      FLAGS[flush]=1
      ORIG_ARGS+=("$1")
      shift
      continue
      ;;
    -g)
      # Set the group to run as.
      (( UID != 0 )) && die "only root can use -g"
      getent group "$2" >/dev/null 2>&1 || die "invalid group given for -g: $2"
      RUNAS_GROUP="$2"
      shift 2
      continue
      ;;
    -h|-help|--help)
      # Show the help screen and exit.
      display_help
      exit 0
      ;;
    -i)
      # Use a pipe/FIFO instead of stdin.
      [[ ! "$2" ]] && die "missing argument to -i"
      [[ "${2:0:1}" != "/" ]] && die "must be an absolute path for -i: $2"
      INPUT="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -j)
      # Set the maximum number of concurrent compression jobs to have active at once.
      [[ ! "$2" =~ [0-9]+ ]] && die "invalid number of jobs: $2"
      (( $2 == 0 )) && die "invalid number of jobs: $2"
      MAXJOBS="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -l)
      # Set the link name to use.
      [[ ! "$2" ]] && die "missing argument to -l"
      [[ "${2:0:1}" == "/" ]] && die "link name cannot begin with '/': $2"
      [[ "${2: -1:1}" == "/" ]] && die "link name cannot end with '/': $2"
      LINKFILE="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -md)
      # Set the directory umask.
      [[ ! "$2" ]] && die "missing argument to -md"
      [[ ! "$2" =~ [0-7]{3,4} ]] && die "invalid umask given for -md: $2"
      DIR_UMASK="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -mf)
      # Set the file umask.
      [[ ! "$2" ]] && die "missing argument to -mf"
      [[ ! "$2" =~ [0-7]{3} ]] && die "invalid umask given for -mf: $2"
      FILE_UMASK="$2"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -mp)
      # Set the pipe umask.
      [[ ! "$2" ]] && die "missing argument to -mp"
      [[ ! "$2" =~ [0-7]{3} ]] && die "invalid umask given for -mp: $2"
      PIPE_UMASK="$2"
      shift 2
      continue
      ;;
    -o)
      # Set the ownership of the pipe/FIFO.
      (( UID != 0 )) && die "only root can use -o"
      [[ ! "$2" ]] && die "missing argument to -o"
      [[ ! "$2" =~ ^.*:.*$ ]] && die "option -o must include a ':': $2"
      [[ -z "${2%%:*}" ]] && [[ -z "${2##*:}" ]] && die "both owner and group missing from -o: $2"
      [[ -n "${2%%:*}" ]] && { getent passwd "${2%%:*}" >/dev/null 2>&1 || die "invalid user part given for -o: $2"; }
      [[ -n "${2##*:}" ]] && { getent group "${2##*:}" >/dev/null 2>&1 || die "invalid group part given for -o: $2"; }
      PIPE_OWNER="$2"
      shift 2
      continue
      ;;
    -p)
      # Create parent directories.
      FLAGS[make-parents]=1
      ORIG_ARGS+=("$1")
      shift
      continue
      ;;
    -r)
      # Set raw mode.
      FLAGS[raw]=1
      ORIG_ARGS+=("$1")
      shift
      continue
      ;;
    -s)
      # Set the syslog facility.
      [[ ! "${2,,}" =~ (auth|authpriv|cron|daemon|ftp|kern|lpr|mail|news|syslog|user|uucp|local[0-7]) ]] && die "invalid syslog facility: $2"
      SYSLOG_FACILITY="${2,,}"
      ORIG_ARGS+=("$1" "$2")
      shift 2
      continue
      ;;
    -u)
      # Set the user to run as.
      (( UID != 0 )) && die "only root can use -u"
      getent passwd "$2" >/dev/null 2>&1 || die "invalid user given for -u: $2"
      RUNAS_USER="$2"
      shift 2
      continue
      ;;
    -v)
      # Show the version and exit.
      display_version
      exit 0
      ;;
    -z)
      # Compress logs once they are rotated.
      FLAGS[compress]=1
      ORIG_ARGS+=("$1")
      shift
      continue
      ;;
    --)
      # Stop processing options.  Everything further is taken as an argument.
      shift
      break
      ;;
    *)
      break
      ;;
  esac
done

# If there isn't 2 arguments left, exit.
(( $# != 2 )) && {
  printf "%s\\n" "$NAME: incorrect number of non-option arguments" >&2
  printf "%s\\n" "Try: $NAME -h" >&2
  exit 1
}

# The remaining arguments should be the base directory and the template.
BASEDIR="${1/%\//}"
TEMPLATE="$2"

# Santy checking.
[[ "${BASEDIR:0:1}" != "/" ]] && die "must be an absolute path: $BASEDIR"
[[ ! -e "$BASEDIR" ]] && die "base directory does not exist: $BASEDIR"
[[ ! -d "$BASEDIR" ]] && die "not a directory: $BASEDIR"
(( "${FLAGS[make-parents]}" == 1 )) && [[ ! -w "$BASEDIR" ]] && die "no write permission: $BASEDIR"
[[ "${TEMPLATE: 0:1}" == "/" ]] && die "template cannot start with '/' - must be a relative path: $TEMPLATE"
[[ "${TEMPLATE: -1:1}" == "/" ]] && die "template cannot end with '/' - path must be a filename: $TEMPLATE"
(( FLAGS[raw] == 0 )) && [[ ! "$TEMPLATE" =~ .*\{\} ]] && die "template must include at least one '{}': $TEMPLATE"
(( FLAGS[raw] != 0 )) && [[ "$TEMPLATE" =~ .*\{\} ]] && die "template cannot include '{}': $TEMPLATE"
(( FLAGS[raw] != 0 )) && [[ "$LINKFILE" =~ .*\{\} ]] && die "link name cannot include '{}': $LINKFILE"

# If input is to be a pipe/FIFO, create it if necessary.
[[ -n "$INPUT" ]] && {
  [[ ! -e "$INPUT" ]] || [[ ! -p "$INPUT" ]] && {
    umask "$PIPE_UMASK"
    rm -f "$INPUT"
    mkfifo "$INPUT" 2>/dev/null || die "failed to create pipe/FIFO: $INPUT"
    FLAGS[created-fifo]=1
    [[ -n "$PIPE_OWNER" ]] && { chown "$PIPE_OWNER" "$INPUT" >/dev/null 2>&1 || die "failed to chown pipe/FIFO: $INPUT"; }
  }
}

# Apply user and setting.
[[ -n "$RUNAS_USER" ]] || [[ -n "$RUNAS_GROUP" ]] && {
  SETPRIV="$(command -v setpriv)"
  if [[ -n "$SETPRIV" ]]; then
    exec "$SETPRIV" --keep-groups --reuid "${RUNAS_USER:-$(whoami)}" ${RUNAS_GROUP:+--regid "$RUNAS_GROUP"} -- "$0" "${ORIG_ARGS[@]}" "$BASEDIR" "$TEMPLATE" || die "failed to exec to change user/group"
  else
    die "cannot exec to change user/group: setpriv not found"
  fi
}

# If input is to be a pipe/FIFO, open it.
# Note: The fifo must be opened in read-write mode in order to avoid blocking.
[[ -n "$INPUT" ]] && { exec {INPUTFD}<>"$INPUT" || die "failed to open pipe/FIFO for reading: $INPUT"; }

# Main loop
while :; do
  # Reset used variables.
  unset LOG_VHOST LOG_DATA LINE
  FLAGS[timed-out]=0

  # Start compression jobs if there's any in the queue.
  start_compression_jobs

  # If debugging is enabled, record the time of entry into the read below.
  (( DEBUG == 1 )) && printf "%s: %s\\n" "$(date "+%Y%m%d %H%M%S.%N")" "enter read" >>"$DEBUG_FILE"

  # Read the log line, but timeout at the top of the next second if nothing is read.
  read -r -t "0.$(( 999999999 - 10#$(date +%N) ))" -u "$INPUTFD" LINE
  ERR="$?"

  # If debugging, record the time of exit from read.
  (( DEBUG == 1 )) && printf "%s: %s: ERR=%s, VHOST=%s, DATA=%s\\n" "$(date "+%Y%m%d %H%M%S.%N")" "exit read" "$ERR" "$LOG_VHOST" "${LOG_DATA:0:50}" >>"$DEBUG_FILE"

  # Determine how the read above was exited.
  if (( ERR > 128 )); then
    # If 'read' timed out, set a marker.
    FLAGS[timed-out]=1
  elif (( ERR == 1 )); then
    (( INPUTFD == 0 )) && {
      # stdin has been closed by the parent, quit gracefully by raising a SIGTERM.
      kill -TERM "$$"
    }
  elif (( ERR != 0 )); then
    # Unhandled error - log the issue and continue.
    syslog "error" "unhandled return code from 'read': $ERR"
    continue
  fi

  # Make sure the base path still exists - it could have disappeared while we were blocked in 'read'.
  # Note: We won't make this directory ourselves - as it's the base directory it should exist on the system to start with.
  if [[ ! -e "$BASEDIR" ]]; then
    (( FLAGS[basedir-vanished] == 0 )) && {
      syslog "error" "base directory has vanished"
      FLAGS[basedir-vanished]=1
    }
    continue
  else
    (( FLAGS[basedir-vanished] == 1 )) && {
      syslog "info" "base directory has reappeared"
      FLAGS[basedir-vanished]=0
    }
  fi

  # Make sure the base path is a directory.
  if ! is_dir "$BASEDIR"; then
    (( FLAGS[basedir-notdir] == 0 )) && {
      syslog "error" "base path is no longer a directory"
      FLAGS[basedir-notdir]=1
    }
    continue
  else
    (( FLAGS[basedir-notdir] == 1 )) && {
      syslog "info" "base path has become directory again"
      FLAGS[basedir-notdir]=0
    }
  fi

  # Extract the data from the read line.
  if (( FLAGS[raw] == 0 )); then
    LOG_VHOST="${LINE%% *}"
    LOG_DATA="${LINE#* }"
  else
    LOG_DATA="$LINE"
  fi

  # Expand the strftime-encoded strings in the template.
  EXPANDED_TEMPLATE="$(printf "%($TEMPLATE)T")"

  # The old expanded template needs to be seeded if it's not already set from a previous loop.
  # Set it to the same as the current expanded template so that no rotation is done the first time around.
  [[ -z "$OLD_TEMPLATE" ]] && OLD_TEMPLATE="$EXPANDED_TEMPLATE"

  # If the 'read' timed out and the exapnded template is the same as the old expanded template, there is no need to do anything.
  (( FLAGS[timed-out] == 1 )) && [[ "$EXPANDED_TEMPLATE" == "$OLD_TEMPLATE" ]] && continue

  # If the 'read' did not time out but the line read is empty, don't do anything.
  (( FLAGS[timed-out] == 0 )) && [[ "$LOG_DATA" =~ ^[[:space:]]*$ ]] && continue

  # If the new expanded template is different from the old, close and reopen all the logs and queue for compression (if required).
  [[ "$EXPANDED_TEMPLATE" != "$OLD_TEMPLATE" ]] && {
    # Loop through all the open FDs.
    for SITE in "${!FDS[@]}"; do
      # Generate the fully expanded filename from the strftime-expanded template and the site name from the array.
      FILENAME="$BASEDIR/${EXPANDED_TEMPLATE//\{\}/$SITE}"

      # Close the file descriptor for the old log file path.
      close_fd "$SITE"

      # Make sure the directory leading up to the expanded part of the template exists.
      # Note: We don't create this part of the template's path tree as doing so would
      # potentially allow any path to be created by use of a custom Host: header.
      check_leading_dirs "$SITE" "$BASEDIR/${TEMPLATE//\{\}/$SITE}" || continue

      # Create what's missing from the full log file's directory based on the expanded template.
      create_missing_dirs "$SITE" "${FILENAME%/*}" || continue

      # Open the new log file.
      open_fd "$SITE" "$FILENAME" || continue

      # Fix the now broken symlink - point it to the currently active log file.
      [[ "$LINKFILE" ]] && {
        LINKFILE_EXPANDED="$(printf "%($LINKFILE)T")"
        # Note: This will clobber anything that already exists with the link name.
        rm -rf "${BASEDIR:?}/${LINKFILE_EXPANDED//\{\}/$SITE}"
        if ! ln -sfr "$FILENAME" "$BASEDIR/${LINKFILE_EXPANDED//\{\}/$SITE}"; then
          (( FLAGS[${SITE}_fix-link] == 0 )) && {
            syslog "error" "failed to fix link: $BASEDIR/${LINKFILE_EXPANDED//\{\}/$SITE}"
            FLAGS[${SITE}_fix-link]=1
          }
          continue
        else
          (( FLAGS[${SITE}_fix-link] == 1 )) && {
            syslog "info" "fixed link: $BASEDIR/${LINKFILE_EXPANDED//\{\}/$SITE}"
            FLAGS[${SITE}_fix-link]=0
          }
        fi
      }
      # Add the old log file to the compression jobs task list.
      (( FLAGS[compress] != 0 )) && {
        JOBS+=([$BASEDIR/${OLD_TEMPLATE//\{\}/$SITE}]="")
      }
    done
  }

  # If the 'read' did not time out, there must be a log line to write.
  (( FLAGS[timed-out] == 0 )) && {
    # If not in raw mode, an unset LOG_VHOST is an error.
    # If in raw mode, we need a placeholder for the FDS array element as LOG_VHOST would normally be unset.
    if (( FLAGS[raw] == 0 )); then
      [[ ! "$LOG_VHOST" ]] && {
        syslog "error" "empty VirtualHost site identifier"
        continue
      }
    else
      LOG_VHOST="_raw_"
    fi

    # Generate the fully expanded filename from the strftime-expanded template.
    FILENAME="$BASEDIR/${EXPANDED_TEMPLATE//\{\}/$LOG_VHOST}"

    # Unless the -p option has been used, make sure the directory leading up to the
    # expanded part of the template exists.
    (( FLAGS[make-parents] == 0 )) && {
      check_leading_dirs "$LOG_VHOST" "$BASEDIR/${TEMPLATE//\{\}/$LOG_VHOST}" || continue
    }

    # Create what's missing from the full log file's directory based on the expanded template.
    create_missing_dirs "$LOG_VHOST" "${FILENAME%/*}" || continue

    # If no FD is open for the LOG_VHOST, open it.
    [[ -z "${FDS[$LOG_VHOST]}" ]] && {
      open_fd "$LOG_VHOST" "$FILENAME" || continue
    }

    # Write the log entry.
    printf "%s\\n" "$LOG_DATA" >&"${FDS[$LOG_VHOST]}"

    # Flush data to disk if requested.
    (( FLAGS[flush] == 1 )) && {
      if ! sync "$FILENAME" 2>/dev/null; then
        (( FLAGS[sync-fail] == 0 )) && {
          syslog "warn" "failed to sync: $FILENAME"
          FLAGS[sync-fail]=1
        }
        continue
      else
        (( FLAGS[sync-fail] == 1 )) && {
          syslog "info" "sync successful: $FILENAME"
          FLAGS[sync-fail]=0
        }
      fi
    }

    # Create symlink to the currently active log file.
    [[ "$LINKFILE" ]] && {
      LINKFILE_EXPANDED="$(printf "%($LINKFILE)T")"
      STAT1="$(stat -L --printf="%d:%i" "$BASEDIR/${LINKFILE_EXPANDED//\{\}/$LOG_VHOST}" 2>/dev/null)"
      STAT2="$(stat --printf="%d:%i" "$FILENAME" 2>/dev/null)"
      [[ "$STAT1" != "$STAT2" ]] && {
        # Note: This will clobber anything that already exists with the link name.
        rm -rf "${BASEDIR:?}/${LINKFILE_EXPANDED//\{\}/$LOG_VHOST}"
        if ! ln -sfr "$FILENAME" "$BASEDIR/${LINKFILE_EXPANDED//\{\}/$LOG_VHOST}" 2>/dev/null; then
          (( FLAGS[${LOG_VHOST}_create-link] == 0 )) && {
            syslog "error" "failed to create link: $BASEDIR/${LINKFILE_EXPANDED//\{\}/$LOG_VHOST}"
            FLAGS[${LOG_VHOST}_create-link]=1
          }
          continue
        else
          (( FLAGS[${LOG_VHOST}_create-link] == 1 )) && {
            syslog "info" "created link: $BASEDIR/${LINKFILE_EXPANDED//\{\}/$LOG_VHOST}"
            FLAGS[${LOG_VHOST}_create-link]=0
          }
        fi
      }
    }
  }

  # Store the last used filename.
  OLD_TEMPLATE="$EXPANDED_TEMPLATE"
done
