#!/usr/bin/perl -w

# Replacement for the openSUSE awstats-update helper.
#
# Default:
#   - update every /etc/awstats/awstats.<name>.conf
#   - fix ownership/mode of AWStats data files afterwards
#
# Options:
#   -config NAME       update only awstats.NAME.conf
#   -logfile FILE      update all configs whose effective source logfile is FILE
#   -previouslog [FILE] use FILE, or auto-detect the newest rotated source log
#   -awstats-option X  pass one additional option to awstats.pl (repeatable)
#   -- ...             pass all remaining arguments to awstats.pl
#   -debug             print commands and successful AWStats output
#   -h, --help         show usage

use strict;
use File::Basename qw(dirname basename);
use File::Find qw(find);
use File::Spec;
use File::Temp qw(tempfile tempdir);

my $DIRCONFIG       = "/etc/awstats";
my $AWSTATSSCRIPT   = "/srv/www/cgi-bin/awstats.pl";
my $DEFAULT_DIRDATA = "/var/cache/awstats";
my $WEB_USER        = "wwwrun";
my $WEB_GROUP       = "www";

my $DEBUG       = 0;
my $PREVIOUSLOG      = 0;
my $PREVIOUSLOG_FILE = "";
my $ONLY_CONFIG      = "";
my $ONLY_LOGFILE = "";
my @AWSTATS_OPTIONS;

sub usage {
    my ($status) = @_;
    $status = 2 unless defined $status;

    my $text = <<'USAGE';
Usage: awstats-update [-config NAME | -logfile FILE] [-previouslog [FILE]] [-debug]
                      [-awstats-option OPTION]... [-- AWSTATS_OPTION ...]

  -config NAME
        Update only /etc/awstats/awstats.NAME.conf.
        -config=NAME is accepted as well.

  -logfile FILE
        Update every awstats.*.conf whose effective LogFile, after following
        Include directives, is FILE.  This is useful when several AWStats
        configs consume the same log.  -logfile=FILE is accepted as well.
        FILE must be an absolute filesystem path.

  -previouslog [FILE]
        Temporarily override the source logfile used by the selected config(s).
        If FILE is omitted, resolve the effective LogFile and automatically use
        its newest uncompressed rotated version.  If FILE is supplied, use that
        exact file instead.  FILE must be an absolute filesystem path.
        --previouslog and --previouslog=FILE are accepted as well.

  -awstats-option OPTION
        Pass OPTION unchanged to awstats.pl.  May be repeated.  The forms
        -awstats-option=OPTION and --awstats-option=OPTION are also accepted.
        Wrapper-controlled options (-config, -configdir, -LogFile and -update)
        are rejected.  Examples: -showdropped, -showcorrupted, -debug=4.
        When AWStats options are supplied, successful AWStats output is shown.

  -- AWSTATS_OPTION ...
        Pass all remaining arguments unchanged to awstats.pl.  The same
        restrictions on wrapper-controlled options apply.

  -debug
        Print wrapper commands, selected files and successful AWStats output.
        This is different from the AWStats option -debug=N; pass that with
        -awstats-option or after --.

  -h, --help
        Show this help.
USAGE

    if ($status == 0) {
        print STDOUT $text;
    }
    else {
        print STDERR $text;
    }

    exit $status;
}

sub shell_display {
    return join(' ', map {
        my $s = $_;
        if ($s =~ /[^A-Za-z0-9_\-\.\/:=]/) {
            $s =~ s/'/'\\''/g;
            $s = "'$s'";
        }
        $s;
    } @_);
}

sub read_file {
    my ($path) = @_;
    open(my $fh, '<', $path) or die "Cannot read $path: $!\n";
    local $/;
    my $data = <$fh>;
    close($fh) or die "Cannot close $path: $!\n";
    return $data;
}

sub parse_simple_value {
    my ($value) = @_;
    $value =~ s/^\s+//;
    $value =~ s/\s+$//;

    if ($value =~ /^"([^"]*)"\s*$/) {
        return $1;
    }
    if ($value =~ /^'([^']*)'\s*$/) {
        return $1;
    }

    # Unquoted values are fine as long as they contain no whitespace.
    return $value if $value !~ /\s/;
    return undef;
}

sub parse_include_value {
    my ($raw, $source) = @_;
    my $value = parse_simple_value($raw);

    die "Cannot parse Include directive in $source: $raw\n"
        unless defined($value) && $value ne '';

    return $value;
}

sub read_effective_config {
    my ($config_path) = @_;
    my %state;
    my %stack;

    _read_effective_config_file($config_path, \%state, \%stack);
    return %state;
}

sub shell_quote_word {
    my ($value) = @_;
    $value =~ s/'/'\\''/g;
    return "'$value'";
}

sub logfile_source_path {
    my ($logfile) = @_;

    return undef unless defined($logfile) && $logfile ne '';

    my $value = $logfile;
    $value =~ s/^\s+//;
    $value =~ s/\s+$//;

    # Plain filesystem LogFile.
    if ($value !~ /\|/) {
        return undef unless File::Spec->file_name_is_absolute($value);
        return File::Spec->canonpath(File::Spec->rel2abs($value));
    }

    # Piped LogFile.  Support the common AWStats form:
    #     converter [args] < /absolute/input/file |
    #
    # Deliberately do not try to implement a general shell parser.  Exactly one
    # simple input redirection must be present; quoted filenames are accepted.
    return undef unless $value =~ /\|\s*$/;

    my @sources;
    while ($value =~ /(?:^|\s)<\s*(?:"([^"]+)"|'([^']+)'|([^\s|;&<>]+))/g) {
        my $source = defined($1) ? $1 : defined($2) ? $2 : $3;
        push @sources, $source;
    }

    return undef unless @sources == 1;
    return undef unless File::Spec->file_name_is_absolute($sources[0]);

    return File::Spec->canonpath(File::Spec->rel2abs($sources[0]));
}

sub logfile_with_source {
    my ($logfile, $new_source) = @_;

    my $old_source = logfile_source_path($logfile);
    die "Cannot determine filesystem source logfile from LogFile: $logfile\n"
        unless defined $old_source;

    # Plain filesystem LogFile: the override is simply the replacement path.
    if ($logfile !~ /\|/) {
        return $new_source;
    }

    my $replacement = '< ' . shell_quote_word($new_source);
    my $count = 0;
    my $rewritten = $logfile;

    $rewritten =~ s{(^|\s)<\s*(?:"[^"]+"|'[^']+'|[^\s|;&<>]+)}{
        $count++;
        $1 . $replacement;
    }eg;

    die "Cannot safely rewrite piped LogFile input redirection: $logfile\n"
        unless $count == 1;

    return $rewritten;
}

sub _read_effective_config_file {
    my ($path, $state, $stack) = @_;

    my $absolute = File::Spec->rel2abs($path);
    $absolute = File::Spec->canonpath($absolute);

    die "AWStats Include loop detected at $absolute\n"
        if $stack->{$absolute};

    open(my $fh, '<', $absolute)
        or die "Cannot read AWStats config/include $absolute: $!\n";

    local $stack->{$absolute} = 1;

    while (my $line = <$fh>) {
        next if $line =~ /^\s*#/;
        next if $line =~ /^\s*$/;

        # AWStats accepts Include "file".  Also accept Include="file" and
        # Include=file because these are common variants in locally maintained
        # configurations.
        if ($line =~ /^\s*Include(?:\s*=\s*|\s+)(.*?)\s*$/) {
            my $include = parse_include_value($1, $absolute);

            my $include_path = $include;
            if (!File::Spec->file_name_is_absolute($include_path)) {
                $include_path = File::Spec->catfile(dirname($absolute), $include_path);
            }

            _read_effective_config_file($include_path, $state, $stack);
            next;
        }

        if ($line =~ /^\s*LogFile\s*=\s*(.*?)\s*$/) {
            my $value = parse_simple_value($1);
            die "Cannot parse LogFile directive in $absolute: $line"
                unless defined $value;
            $state->{LogFile} = $value;
            next;
        }

        if ($line =~ /^\s*DirData\s*=\s*(.*?)\s*$/) {
            my $value = parse_simple_value($1);
            die "Cannot parse DirData directive in $absolute: $line"
                unless defined $value;
            $state->{DirData} = $value;
            next;
        }
    }

    close($fh) or die "Cannot close $absolute: $!\n";
}

sub find_latest_previous_log {
    my ($source_logfile) = @_;

    die "-previouslog requires an absolute filesystem source logfile; got: $source_logfile\n"
        unless File::Spec->file_name_is_absolute($source_logfile);

    my $dir  = dirname($source_logfile);
    my $base = basename($source_logfile);

    # Also recognize logrotate's "extension" form:
    #
    #   access.log -> access.1.log
    #
    # in addition to:
    #
    #   access.log.1
    #   access.log-20260921
    #   access.log-2026092113
    #   access.log_<custom dateformat>
    #
    my ($stem, $ext);
    if ($base =~ /^(.*)(\.[^.]+)$/) {
        ($stem, $ext) = ($1, $2);
    }

    opendir(my $dh, $dir)
        or die "Cannot scan log directory $dir: $!\n";

    my @candidates;

    while (my $entry = readdir($dh)) {
        next if $entry eq '.' || $entry eq '..';
        next if $entry eq $base;

        my $matches = 0;

        # Normal numbered rotation and dateext/dateformat:
        #
        #   logfile.1
        #   logfile-20260921
        #   logfile_2026-09-21-13
        #
        if ($entry =~ /^\Q$base\E(?:[._-]|\d).+$/) {
            $matches = 1;
        }

        # "extension" can move the original final extension:
        #
        #   logfile.log -> logfile.1.log
        #
        if (!$matches && defined($stem) && defined($ext)) {
            if ($entry =~ /^\Q$stem\E(?:[._-]|\d).+\Q$ext\E$/) {
                $matches = 1;
            }
        }

        next unless $matches;

        my $path = File::Spec->catfile($dir, $entry);
        next unless -f $path;

        # AWStats expects an uncompressed text log.  This also avoids tying
        # the wrapper to logrotate's configured compression extension.
        next unless -T $path;

        my @st = stat($path);
        next unless @st;

        push @candidates, [
            $st[9],     # mtime
            $st[10],    # ctime, tie breaker
            $entry,
            $path
        ];
    }

    closedir($dh);

    die "No uncompressed rotated log found for $source_logfile\n"
        unless @candidates;

    @candidates = sort {
           $b->[0] <=> $a->[0]
        || $b->[1] <=> $a->[1]
        || $b->[2] cmp $a->[2]
    } @candidates;

    return $candidates[0]->[3];
}

sub validate_awstats_option {
    my ($option) = @_;

    defined($option) && $option ne ''
        or die "Empty AWStats option is not allowed\n";
    $option =~ /^-/
        or die "AWStats option must begin with '-': $option\n";

    # These are controlled by this wrapper.  Allowing callers to override them
    # would make config selection, -previouslog and exit/error handling
    # ambiguous.
    if ($option =~ /^-(?:config(?:=|$)|configdir(?:=|$)|logfile(?:=|$)|update(?:=|$))/i) {
        die "AWStats option is controlled by awstats-update and cannot be passed through: $option\n";
    }

    return $option;
}

sub create_logfile_override_config {
    my ($domain, $original_config, $logfile_override) = @_;

    # The generated values are quoted with AWStats' normal double-quoted config
    # syntax.  Reject characters that would escape that syntax rather than
    # trying to invent another AWStats parser here.
    for my $value ($original_config, $logfile_override) {
        die "Cannot put newline in temporary AWStats config\n"
            if $value =~ /[\r\n]/;
        die "Cannot safely quote double quote in temporary AWStats config value: $value\n"
            if $value =~ /"/;
    }

    my $tmpdir = tempdir('awstats-update.config.XXXXXX', DIR => '/tmp', CLEANUP => 0);
    my $tmpconfig = File::Spec->catfile($tmpdir, "awstats.$domain.conf");

    open(my $fh, '>', $tmpconfig)
        or die "Cannot create temporary AWStats config $tmpconfig: $!\n";
    print $fh "# Generated by awstats-update for -previouslog.\n";
    print $fh qq{Include "$original_config"\n};
    print $fh qq{LogFile="$logfile_override"\n};
    close($fh)
        or die "Cannot close temporary AWStats config $tmpconfig: $!\n";

    return ($tmpdir, $tmpconfig);
}

sub remove_logfile_override_config {
    my ($tmpdir, $tmpconfig) = @_;

    if (defined($tmpconfig) && -e $tmpconfig) {
        unlink($tmpconfig)
            or warn "Cannot remove temporary AWStats config $tmpconfig: $!\n";
    }
    if (defined($tmpdir) && -d $tmpdir) {
        rmdir($tmpdir)
            or warn "Cannot remove temporary AWStats config directory $tmpdir: $!\n";
    }
}

sub run_awstats {
    my ($domain, $configdir_override) = @_;

    my @cmd = ($AWSTATSSCRIPT, "-config=$domain");
    push @cmd, "-configdir=$configdir_override"
        if defined($configdir_override) && $configdir_override ne '';
    push @cmd, '-update';
    push @cmd, @AWSTATS_OPTIONS;

    print "Running: ", shell_display(@cmd), "\n" if $DEBUG;

    my ($capture_fh, $capture_path) = tempfile('awstats-update.output.XXXXXX', TMPDIR => 1, UNLINK => 0);
    close($capture_fh);

    my $pid = fork();
    die "fork failed: $!\n" unless defined $pid;

    if ($pid == 0) {
        open(STDOUT, '>', $capture_path) or die "Cannot redirect stdout: $!\n";
        open(STDERR, '>&', \*STDOUT) or die "Cannot redirect stderr: $!\n";
        exec @cmd;
        die "Cannot exec $AWSTATSSCRIPT: $!\n";
    }

    waitpid($pid, 0);
    my $status = $?;
    my $output = read_file($capture_path);
    unlink($capture_path);

    # AWStats itself prints many fatal errors to stdout.  On failure, promote
    # all captured child output to stderr so cron and callers get an unambiguous
    # error stream.  On success it remains quiet unless wrapper debug is on
    # or AWStats options were explicitly requested.
    if ($status != 0) {
        print STDERR $output;
    }
    elsif ($DEBUG || @AWSTATS_OPTIONS) {
        print STDOUT $output;
    }

    if ($status == -1) {
        warn "AWStats update for $domain could not be executed\n";
        return 1;
    }
    if ($status & 127) {
        warn sprintf("AWStats update for %s died from signal %d\n", $domain, ($status & 127));
        return 1;
    }

    my $rc = $status >> 8;
    if ($rc != 0) {
        warn "AWStats update for $domain failed with exit status $rc\n";
        return 1;
    }

    return 0;
}

sub fix_data_permissions {
    my (@dirs) = @_;

    my $uid = getpwnam($WEB_USER);
    defined($uid) or die "User $WEB_USER does not exist\n";
    my $gid = getgrnam($WEB_GROUP);
    defined($gid) or die "Group $WEB_GROUP does not exist\n";

    my %seen;
    for my $dir (@dirs) {
        next if !defined($dir) || $dir eq '';
        next if $seen{$dir}++;
        next unless -d $dir;

        print "Fixing AWStats data ownership/modes under $dir\n" if $DEBUG;

        find({
            no_chdir => 1,
            wanted   => sub {
                my $path = $File::Find::name;
                my @st = lstat($path);
                return unless @st;

                # Match the old cron behaviour: files/directories created by
                # the root-run update become owned by the web server account.
                if ($st[4] == 0) {
                    chown($uid, $gid, $path) == 1
                        or warn "Cannot chown $path to $WEB_USER:$WEB_GROUP: $!\n";
                    @st = lstat($path);
                    return unless @st;
                }

                # Match the former chmod command: regular files must not be
                # world-readable or world-writable; preserve all other bits.
                if (-f _) {
                    my $mode = $st[2] & 07777;
                    my $new_mode = $mode & ~0006;
                    if ($new_mode != $mode) {
                        chmod($new_mode, $path) == 1
                            or warn sprintf("Cannot chmod %04o %s: %s\n", $new_mode, $path, $!);
                    }
                }
            },
        }, $dir);
    }
}

# --------------------------------------------------------------------------
# Argument parsing
# --------------------------------------------------------------------------
while (@ARGV) {
    my $arg = shift @ARGV;

    if ($arg eq '-config') {
        @ARGV or usage(2);
        $ONLY_CONFIG = shift @ARGV;
    }
    elsif ($arg =~ /^-config=(.+)$/) {
        $ONLY_CONFIG = $1;
    }
    elsif ($arg eq '-logfile') {
        @ARGV or usage(2);
        $ONLY_LOGFILE = shift @ARGV;
    }
    elsif ($arg =~ /^-logfile=(.+)$/) {
        $ONLY_LOGFILE = $1;
    }
    elsif ($arg eq '-previouslog' || $arg eq '--previouslog') {
        $PREVIOUSLOG = 1;

        # Consume an optional filename, but never consume the next option.
        # Absolute-path validation is done below so a relative filename gets a
        # useful error instead of being reported as an unknown option.
        $PREVIOUSLOG_FILE = '';
        if (@ARGV && $ARGV[0] !~ /^-/) {
            $PREVIOUSLOG_FILE = shift @ARGV;
        }
    }
    elsif ($arg =~ /^--?previouslog=(.+)$/) {
        $PREVIOUSLOG = 1;
        $PREVIOUSLOG_FILE = $1;
    }
    elsif ($arg eq '-awstats-option' || $arg eq '--awstats-option') {
        @ARGV or usage(2);
        push @AWSTATS_OPTIONS, validate_awstats_option(shift @ARGV);
    }
    elsif ($arg =~ /^--?awstats-option=(.+)$/) {
        push @AWSTATS_OPTIONS, validate_awstats_option($1);
    }
    elsif ($arg eq '--') {
        while (@ARGV) {
            push @AWSTATS_OPTIONS, validate_awstats_option(shift @ARGV);
        }
        last;
    }
    elsif ($arg eq '-debug') {
        $DEBUG = 1;
    }
    elsif ($arg eq '-h' || $arg eq '--help') {
        usage(0);
    }
    else {
        die "Unknown option: $arg\n";
    }
}

$> == 0 or die "awstats-update must be run as root\n";
-x $AWSTATSSCRIPT or die "AWStats script is not executable: $AWSTATSSCRIPT\n";
-d $DIRCONFIG or die "AWStats config directory does not exist: $DIRCONFIG\n";

if ($ONLY_CONFIG ne '') {
    # Accept NAME, awstats.NAME.conf, or NAME.conf for convenience.
    $ONLY_CONFIG =~ s/^awstats\.//;
    $ONLY_CONFIG =~ s/\.conf$//;
    $ONLY_CONFIG =~ /^[A-Za-z0-9_.-]+$/
        or die "Invalid config name: $ONLY_CONFIG\n";
}

if ($ONLY_CONFIG ne '' && $ONLY_LOGFILE ne '') {
    die "-config and -logfile are mutually exclusive\n";
}

if ($ONLY_LOGFILE ne '') {
    File::Spec->file_name_is_absolute($ONLY_LOGFILE)
        or die "-logfile requires an absolute filesystem source path: $ONLY_LOGFILE\n";
    $ONLY_LOGFILE = File::Spec->canonpath(File::Spec->rel2abs($ONLY_LOGFILE));
}

if ($PREVIOUSLOG_FILE ne '') {
    File::Spec->file_name_is_absolute($PREVIOUSLOG_FILE)
        or die "-previouslog FILE requires an absolute filesystem path: $PREVIOUSLOG_FILE\n";
    $PREVIOUSLOG_FILE = File::Spec->canonpath(File::Spec->rel2abs($PREVIOUSLOG_FILE));
    -f $PREVIOUSLOG_FILE
        or die "-previouslog FILE is not a regular file: $PREVIOUSLOG_FILE\n";
    -T $PREVIOUSLOG_FILE
	    or die "-previouslog requires an uncompressed text logfile: "
	         . "$PREVIOUSLOG_FILE\n";
}

opendir(my $cfgdh, $DIRCONFIG) or die "Cannot scan $DIRCONFIG: $!\n";
my @configs;
while (my $entry = readdir($cfgdh)) {
    next unless $entry =~ /^awstats\.(.+)\.conf$/;
    my $domain = $1;
    next if $ONLY_CONFIG ne '' && $domain ne $ONLY_CONFIG;
    my $path = File::Spec->catfile($DIRCONFIG, $entry);
    next unless -f $path;
    push @configs, [$domain, $path];
}
closedir($cfgdh);

@configs = sort { $a->[0] cmp $b->[0] } @configs;

if ($ONLY_CONFIG ne '' && !@configs) {
    die "Config not found: $DIRCONFIG/awstats.$ONLY_CONFIG.conf\n";
}
if (!@configs) {
    die "No AWStats configuration files found in $DIRCONFIG\n";
}

my %data_dirs = ($DEFAULT_DIRDATA => 1);
my $failures = 0;
my $matched_configs = 0;

for my $cfg (@configs) {
    my ($domain, $config_path) = @$cfg;

    print "Updating AWStats config: $domain\n" if $DEBUG;

    my %effective;
    my $ok = eval {
        %effective = read_effective_config($config_path);
        1;
    };

    if (!$ok) {
        warn $@;
        $failures++;
        next;
    }

    if ($ONLY_LOGFILE ne '') {
        my $effective_source = logfile_source_path($effective{LogFile});
        next unless defined($effective_source) && $effective_source eq $ONLY_LOGFILE;

        print "Matched $domain by source logfile: $effective_source\n" if $DEBUG;
    }

    $matched_configs++;

    if (defined($effective{DirData}) &&
        $effective{DirData} ne '' &&
        File::Spec->file_name_is_absolute($effective{DirData})) {
        $data_dirs{$effective{DirData}} = 1;
    }

    if ($PREVIOUSLOG) {
        if (!defined($effective{LogFile}) || $effective{LogFile} eq '') {
            warn "No effective LogFile directive found for $domain\n";
            $failures++;
            next;
        }

        my ($source_logfile, $previous_log, $logfile_override);
        $ok = eval {
            $source_logfile = logfile_source_path($effective{LogFile});
            die "Cannot determine filesystem source logfile from LogFile for $domain: $effective{LogFile}\n"
                unless defined $source_logfile;

            $previous_log = $PREVIOUSLOG_FILE ne ''
                ? $PREVIOUSLOG_FILE
                : find_latest_previous_log($source_logfile);
            $logfile_override = logfile_with_source($effective{LogFile}, $previous_log);
            1;
        };

        if (!$ok) {
            warn $@;
            $failures++;
            next;
        }

        print "Effective LogFile for $domain: $effective{LogFile}\n" if $DEBUG;
        print "Source logfile for $domain: $source_logfile\n" if $DEBUG;
        print "Previous source log for $domain: $previous_log\n" if $DEBUG;
        print "AWStats LogFile override for $domain: $logfile_override\n" if $DEBUG;

        my ($tmpdir, $tmpconfig);
        $ok = eval {
            ($tmpdir, $tmpconfig) = create_logfile_override_config(
                $domain, $config_path, $logfile_override
            );
            1;
        };

        if (!$ok) {
            warn $@;
            $failures++;
            next;
        }

        print "Temporary AWStats config for $domain: $tmpconfig\n" if $DEBUG;
        my $rc = run_awstats($domain, $tmpdir);
        remove_logfile_override_config($tmpdir, $tmpconfig);
        $failures++ if $rc != 0;
    }
    else {
        $failures++ if run_awstats($domain, undef) != 0;
    }
}

if ($ONLY_LOGFILE ne '' && $matched_configs == 0) {
    warn "No AWStats configs use source logfile $ONLY_LOGFILE\n";
    $failures++;
}

# Always repair output ownership/modes, even if one update failed.  This also
# replaces the two find/chown/chmod commands formerly needed in cron.
fix_data_permissions(sort keys %data_dirs);

exit($failures ? 1 : 0);
