# -*- Mode: perl; indent-tabs-mode: nil -*-
#
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Bug Tracking System.
#
# Contributor(s): Max Kanat-Alexander <mkanat@bugzilla.org>
#                 Bill Barry <after.fallout@gmail.com>

package Bugzilla::Install::Filesystem;

# NOTE: This package may "use" any modules that it likes,
# and localconfig is available. However, all functions in this
# package should assume that:
#
# * Templates are not available.
# * Files do not have the correct permissions.
# * The database does not exist.

use strict;

use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Install::Localconfig;
use Bugzilla::Util;

use File::Find;
use File::Path;
use File::Basename;
use IO::File;
use POSIX ();

use base qw(Exporter);
our @EXPORT = qw(
    update_filesystem
    create_htaccess
    fix_all_file_permissions
);

# This looks like a constant because it effectively is, but
# it has to call other subroutines and read the current filesystem,
# so it's defined as a sub. This is not exported, so it doesn't have
# a perldoc. However, look at the various hashes defined inside this 
# function to understand what it returns. (There are comments throughout.)
#
# The rationale for the file permissions is that the web server generally 
# runs as apache, so the cgi scripts should not be writable for apache,
# otherwise someone may find it possible to change the cgis when exploiting
# some security flaw somewhere (not necessarily in Bugzilla!)
sub FILESYSTEM {
    my $datadir       = bz_locations()->{'datadir'};
    my $attachdir     = bz_locations()->{'attachdir'};
    my $extensionsdir = bz_locations()->{'extensionsdir'};
    my $webdotdir     = bz_locations()->{'webdotdir'};
    my $templatedir   = bz_locations()->{'templatedir'};
    my $libdir        = bz_locations()->{'libpath'};
    my $extlib        = bz_locations()->{'ext_libpath'};
    my $skinsdir      = bz_locations()->{'skinsdir'};

    my $ws_group      = Bugzilla->localconfig->{'webservergroup'};

    # The set of permissions that we use:

    # FILES
    # Executable by the web server
    my $ws_executable = $ws_group ? 0750 : 0755;
    # Executable by the owner only.
    my $owner_executable = 0700;
    # Readable by the web server.
    my $ws_readable = $ws_group ? 0640 : 0644;
    # Readable by the owner only.
    my $owner_readable = 0600;
    # Writeable by the web server.
    my $ws_writeable = $ws_group ? 0660 : 0666;

    # DIRECTORIES
    # Readable by the web server.
    my $ws_dir_readable  = $ws_group ? 0750 : 0755;
    # Readable only by the owner.
    my $owner_dir_readable = 0700;
    # Writeable by the web server.
    my $ws_dir_writeable = $ws_group ? 0770 : 01777;
    # The web server can overwrite files owned by other users, 
    # in this directory.
    my $ws_dir_full_control = $ws_group ? 0770 : 0777;

    # Note: When being processed by checksetup, these have their permissions
    # set in this order: %all_dirs, %recurse_dirs, %all_files.
    #
    # Each is processed in alphabetical order of keys, so shorter keys
    # will have their permissions set before longer keys (thus setting
    # the permissions on parent directories before setting permissions
    # on their children).

    # --- FILE PERMISSIONS (Non-created files) --- #
    my %files = (
        '*'               => { perms => $ws_readable },
        '*.cgi'           => { perms => $ws_executable },
        'whineatnews.pl'  => { perms => $ws_executable },
        'collectstats.pl' => { perms => $ws_executable },
        'checksetup.pl'   => { perms => $owner_executable },
        'importxml.pl'    => { perms => $ws_executable },
        'runtests.pl'     => { perms => $owner_executable },
        'testserver.pl'   => { perms => $ws_executable },
        'whine.pl'        => { perms => $ws_executable },
        'customfield.pl'  => { perms => $owner_executable },
        'email_in.pl'     => { perms => $ws_executable },
        'sanitycheck.pl'  => { perms => $ws_executable },
        'install-module.pl' => { perms => $owner_executable },

        'docs/makedocs.pl'   => { perms => $owner_executable },
        'docs/style.css'       => { perms => $ws_readable },
        'docs/*/rel_notes.txt' => { perms => $ws_readable },
        'docs/*/README.docs'   => { perms => $owner_readable },
        "$datadir/bugzilla-update.xml" => { perms => $ws_writeable },
        "$datadir/params" => { perms => $ws_writeable },
        "$datadir/mailer.testfile" => { perms => $ws_writeable },
    );

    # Directories that we want to set the perms on, but not
    # recurse through. These are directories we didn't create
    # in checkesetup.pl.
    my %non_recurse_dirs = (
        '.'  => $ws_dir_readable,
        docs => $ws_dir_readable,
    );

    # This sets the permissions for each item inside each of these 
    # directories, including the directory itself. 
    # 'CVS' directories are special, though, and are never readable by 
    # the webserver.
    my %recurse_dirs = (
        # Writeable directories
        "$datadir/template" => { files => $ws_readable, 
                                  dirs => $ws_dir_full_control },
         $attachdir         => { files => $ws_writeable,
                                  dirs => $ws_dir_writeable },
         $webdotdir         => { files => $ws_writeable,
                                  dirs => $ws_dir_writeable },
         graphs             => { files => $ws_writeable,
                                  dirs => $ws_dir_writeable },

         # Readable directories
         "$datadir/mining"     => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         "$datadir/duplicates" => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         "$libdir/Bugzilla"    => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         $extlib               => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         $templatedir          => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         $extensionsdir        => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         images                => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         css                   => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         js                    => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         $skinsdir             => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         t                     => { files => $owner_readable,
                                     dirs => $owner_dir_readable },
         'docs/*/html'         => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         'docs/*/pdf'          => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         'docs/*/txt'          => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         'docs/*/images'       => { files => $ws_readable,
                                     dirs => $ws_dir_readable },
         'docs/lib'            => { files => $owner_readable,
                                     dirs => $owner_dir_readable },
         'docs/*/xml'          => { files => $owner_readable,
                                     dirs => $owner_dir_readable },
    );

    # --- FILES TO CREATE --- #

    # The name of each directory that we should actually *create*,
    # pointing at its default permissions.
    my %create_dirs = (
        $datadir                => $ws_dir_full_control,
        "$datadir/mining"       => $ws_dir_readable,
        "$datadir/duplicates"   => $ws_dir_readable,
        $attachdir              => $ws_dir_writeable,
        $extensionsdir          => $ws_dir_readable,
        graphs                  => $ws_dir_writeable,
        $webdotdir              => $ws_dir_writeable,
        "$skinsdir/custom"      => $ws_dir_readable,
        "$skinsdir/contrib"     => $ws_dir_readable,
    );

    # The name of each file, pointing at its default permissions and
    # default contents.
    my %create_files = ();

    # Each standard stylesheet has an associated custom stylesheet that
    # we create. Also, we create placeholders for standard stylesheets
    # for contrib skins which don't provide them themselves.
    foreach my $skin_dir ("$skinsdir/custom", <$skinsdir/contrib/*>) {
        next if basename($skin_dir) =~ /^cvs$/i;
        $create_dirs{"$skin_dir/yui"} = $ws_dir_readable;
        foreach my $base_css (<$skinsdir/standard/*.css>) {
            _add_custom_css($skin_dir, basename($base_css), \%create_files, $ws_readable);
        }
        foreach my $dir_css (<$skinsdir/standard/*/*.css>) {
            $dir_css =~ s{.+?([^/]+/[^/]+)$}{$1};
            _add_custom_css($skin_dir, $dir_css, \%create_files, $ws_readable);
        }
    }

    # Because checksetup controls the creation of index.html separately
    # from all other files, it gets its very own hash.
    my %index_html = (
        'index.html' => { perms => $ws_readable, contents => <<EOT
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
  <meta http-equiv="Refresh" content="0; URL=index.cgi">
</head>
<body>
  <h1>I think you are looking for <a href="index.cgi">index.cgi</a></h1>
</body>
</html>
EOT
        }
    );

    # Because checksetup controls the .htaccess creation separately
    # by a localconfig variable, these go in a separate variable from
    # %create_files.
    my $ht_default_deny = <<EOT;
# nothing in this directory is retrievable unless overridden by an .htaccess
# in a subdirectory
deny from all
EOT

    my %htaccess = (
        "$attachdir/.htaccess"       => { perms    => $ws_readable,
                                          contents => $ht_default_deny },
        "$libdir/Bugzilla/.htaccess" => { perms    => $ws_readable,
                                          contents => $ht_default_deny },
        "$extlib/.htaccess"          => { perms    => $ws_readable,
                                          contents => $ht_default_deny },
        "$templatedir/.htaccess"     => { perms    => $ws_readable,
                                          contents => $ht_default_deny },

        '.htaccess' => { perms => $ws_readable, contents => <<EOT
# Don't allow people to retrieve non-cgi executable files or our private data
<FilesMatch ^(.*\\.pm|.*\\.pl|.*localconfig.*)\$>
  deny from all
</FilesMatch>
EOT
        },

        "$webdotdir/.htaccess" => { perms => $ws_readable, contents => <<EOT
# Restrict access to .dot files to the public webdot server at research.att.com
# if research.att.com ever changes their IP, or if you use a different
# webdot server, you'll need to edit this
<FilesMatch \\.dot\$>
  Allow from 192.20.225.0/24
  Deny from all
</FilesMatch>

# Allow access to .png files created by a local copy of 'dot'
<FilesMatch \\.png\$>
  Allow from all
</FilesMatch>

# And no directory listings, either.
Deny from all
EOT
        },

        # Even though $datadir may not (and should not) be accessible from the 
        # web server, we can't know for sure, so create the .htaccess anyway. 
        # It's harmless if it isn't accessible...
        "$datadir/.htaccess" => { perms    => $ws_readable, contents => <<EOT
# Nothing in this directory is retrievable unless overridden by an .htaccess
# in a subdirectory; the only exception is duplicates.rdf, which is used by
# duplicates.xul and must be accessible from the web server
deny from all
<Files duplicates.rdf>
  allow from all
</Files>
EOT


        },
    );

    my %all_files = (%create_files, %htaccess, %index_html, %files);
    my %all_dirs  = (%create_dirs, %non_recurse_dirs);

    return {
        create_dirs  => \%create_dirs,
        recurse_dirs => \%recurse_dirs,
        all_dirs     => \%all_dirs,

        create_files => \%create_files,
        htaccess     => \%htaccess,
        index_html   => \%index_html,
        all_files    => \%all_files,
    };
}

sub update_filesystem {
    my ($params) = @_;
    my $fs = FILESYSTEM();
    my %dirs  = %{$fs->{create_dirs}};
    my %files = %{$fs->{create_files}};

    my $datadir = bz_locations->{'datadir'};
    # If the graphs/ directory doesn't exist, we're upgrading from
    # a version old enough that we need to update the $datadir/mining 
    # format.
    if (-d "$datadir/mining" && !-d 'graphs') {
        _update_old_charts($datadir);
    }

    # By sorting the dirs, we assure that shorter-named directories
    # (meaning parent directories) are always created before their
    # child directories.
    foreach my $dir (sort keys %dirs) {
        unless (-d $dir) {
            print "Creating $dir directory...\n";
            mkdir $dir || die $!;
            # For some reason, passing in the permissions to "mkdir"
            # doesn't work right, but doing a "chmod" does.
            chmod $dirs{$dir}, $dir || die $!;
        }
    }

    _create_files(%files);
    if ($params->{index_html}) {
        _create_files(%{$fs->{index_html}});
    }
    elsif (-e 'index.html') {
        my $templatedir = bz_locations()->{'templatedir'};
        print <<EOT;

*** It appears that you still have an old index.html hanging around.
    Either the contents of this file should be moved into a template and 
    placed in the '$templatedir/en/custom' directory, or you should delete 
    the file.

EOT
    }

    # Delete old files that no longer need to exist

    # 2001-04-29 jake@bugzilla.org - Remove oldemailtech
    #   http://bugzilla.mozilla.org/show_bugs.cgi?id=71552
    if (-d 'shadow') {
        print "Removing shadow directory...\n";
        rmtree("shadow");
    }

    if (-e "$datadir/versioncache") {
        print "Removing versioncache...\n";
        unlink "$datadir/versioncache";
    }

}

# A simple helper for creating "empty" CSS files.
sub _add_custom_css {
    my ($skin_dir, $path, $create_files, $perms) = @_;
    $create_files->{"$skin_dir/$path"} = { perms => $perms, contents => <<EOT
/*
 * Custom rules for $path.
 * The rules you put here override rules in that stylesheet.
 */
EOT
    };
}

sub create_htaccess {
    _create_files(%{FILESYSTEM()->{htaccess}});

    # Repair old .htaccess files
    my $htaccess = new IO::File('.htaccess', 'r') || die ".htaccess: $!";
    my $old_data;
    { local $/; $old_data = <$htaccess>; }
    $htaccess->close;

    my $repaired = 0;
    if ($old_data =~ s/\|localconfig\|/\|.*localconfig.*\|/) {
        $repaired = 1;
    }
    if ($old_data !~ /\(\.\*\\\.pm\|/) {
        $old_data =~ s/\(/(.*\\.pm\|/;
        $repaired = 1;
    }
    if ($repaired) {
        print "Repairing .htaccess...\n";
        $htaccess = new IO::File('.htaccess', 'w') || die $!;
        print $htaccess $old_data;
        $htaccess->close;
    }


    my $webdot_dir = bz_locations()->{'webdotdir'};
    # The public webdot IP address changed.
    my $webdot = new IO::File("$webdot_dir/.htaccess", 'r')
        || die "$webdot_dir/.htaccess: $!";
    my $webdot_data;
    { local $/; $webdot_data = <$webdot>; }
    $webdot->close;
    if ($webdot_data =~ /192\.20\.225\.10/) {
        print "Repairing $webdot_dir/.htaccess...\n";
        $webdot_data =~ s/192\.20\.225\.10/192.20.225.0\/24/g;
        $webdot = new IO::File("$webdot_dir/.htaccess", 'w') || die $!;
        print $webdot $webdot_data;
        $webdot->close;
    }
}

# A helper for the above functions.
sub _create_files {
    my (%files) = @_;

    # It's not necessary to sort these, but it does make the
    # output of checksetup.pl look a bit nicer.
    foreach my $file (sort keys %files) {
        unless (-e $file) {
            print "Creating $file...\n";
            my $info = $files{$file};
            my $fh = new IO::File($file, O_WRONLY | O_CREAT, $info->{perms})
                || die $!;
            print $fh $info->{contents} if $info->{contents};
            $fh->close;
        }
    }
}

# If you ran a REALLY old version of Bugzilla, your chart files are in the
# wrong format. This code is a little messy, because it's very old, and
# when moving it into this module, I couldn't test it so I left it almost 
# completely alone.
sub _update_old_charts {
    my ($datadir) = @_;
    print "Updating old chart storage format...\n";
    foreach my $in_file (glob("$datadir/mining/*")) {
        # Don't try and upgrade image or db files!
        next if (($in_file =~ /\.gif$/i) ||
                 ($in_file =~ /\.png$/i) ||
                 ($in_file =~ /\.db$/i) ||
                 ($in_file =~ /\.orig$/i));

        rename("$in_file", "$in_file.orig") or next;
        open(IN, "$in_file.orig") or next;
        open(OUT, '>', $in_file) or next;

        # Fields in the header
        my @declared_fields;

        # Fields we changed to half way through by mistake
        # This list comes from an old version of collectstats.pl
        # This part is only for people who ran later versions of 2.11 (devel)
        my @intermediate_fields = qw(DATE UNCONFIRMED NEW ASSIGNED REOPENED
                                     RESOLVED VERIFIED CLOSED);

        # Fields we actually want (matches the current collectstats.pl)
        my @out_fields = qw(DATE NEW ASSIGNED REOPENED UNCONFIRMED RESOLVED
                            VERIFIED CLOSED FIXED INVALID WONTFIX LATER REMIND
                            DUPLICATE WORKSFORME MOVED);

         while (<IN>) {
            if (/^# fields?: (.*)\s$/) {
                @declared_fields = map uc, (split /\||\r/, $1);
                print OUT "# fields: ", join('|', @out_fields), "\n";
            }
            elsif (/^(\d+\|.*)/) {
                my @data = split(/\||\r/, $1);
                my %data;
                if (@data == @declared_fields) {
                    # old format
                    for my $i (0 .. $#declared_fields) {
                        $data{$declared_fields[$i]} = $data[$i];
                    }
                }
                elsif (@data == @intermediate_fields) {
                    # Must have changed over at this point
                    for my $i (0 .. $#intermediate_fields) {
                        $data{$intermediate_fields[$i]} = $data[$i];
                    }
                }
                elsif (@data == @out_fields) {
                    # This line's fine - it has the right number of entries
                    for my $i (0 .. $#out_fields) {
                        $data{$out_fields[$i]} = $data[$i];
                    }
                }
                else {
                    print "Oh dear, input line $. of $in_file had " .
                          scalar(@data) . " fields\nThis was unexpected.",
                          " You may want to check your data files.\n";
                }

                print OUT join('|', 
                    map { defined ($data{$_}) ? ($data{$_}) : "" } @out_fields),
                    "\n";
            }
            else {
                print OUT;
            }
        }

        close(IN);
        close(OUT);
    } 
}


sub fix_all_file_permissions {
    my ($output) = @_;

    my $ws_group = Bugzilla->localconfig->{'webservergroup'};
    my $group_id = _check_web_server_group($ws_group, $output);

    return if ON_WINDOWS;

    my $fs = FILESYSTEM();
    my %files = %{$fs->{all_files}};
    my %dirs  = %{$fs->{all_dirs}};
    my %recurse_dirs = %{$fs->{recurse_dirs}};

    print get_text('install_file_perms_fix') . "\n" if $output;

    my $owner_id = POSIX::getuid();
    $group_id = POSIX::getgid() unless defined $group_id;

    foreach my $dir (sort keys %dirs) {
        next unless -d $dir;
        _fix_perms($dir, $owner_id, $group_id, $dirs{$dir});
    }

    foreach my $dir (sort keys %recurse_dirs) {
        next unless -d $dir;
        # Set permissions on the directory itself.
        my $perms = $recurse_dirs{$dir};
        _fix_perms($dir, $owner_id, $group_id, $perms->{dirs});
        # Now recurse through the directory and set the correct permissions
        # on subdirectories and files.
        find({ no_chdir => 1, wanted => sub {
            my $name = $File::Find::name;
            if (-d $name) {
                _fix_perms($name, $owner_id, $group_id, $perms->{dirs});
            }
            else {
                _fix_perms($name, $owner_id, $group_id, $perms->{files});
            }
        }}, $dir);
    }

    foreach my $file (sort keys %files) {
        # %files supports globs
        foreach my $filename (glob $file) {
            # Don't touch directories.
            next if -d $filename || !-e $filename;
            _fix_perms($filename, $owner_id, $group_id, 
                       $files{$file}->{perms});
        }
    }

    _fix_cvs_dirs($owner_id, '.');
}

# A helper for fix_all_file_permissions
sub _fix_cvs_dirs {
    my ($owner_id, $dir) = @_;
    my $owner_gid = POSIX::getgid();
    find({ no_chdir => 1, wanted => sub {
        my $name = $File::Find::name;
        if ($File::Find::dir =~ /\/CVS/ || $_ eq '.cvsignore'
            || (-d $name && $_ eq 'CVS')) {
            _fix_perms($name, $owner_id, $owner_gid, 0700);
        }
    }}, $dir);
}

sub _fix_perms {
    my ($name, $owner, $group, $perms) = @_;
    #printf ("Changing $name to %o\n", $perms);
    chown $owner, $group, $name 
        || warn "Failed to change ownership of $name: $!";
    chmod $perms, $name
        || warn "Failed to change permissions of $name: $!";
}

sub _check_web_server_group {
    my ($group, $output) = @_;

    my $filename = bz_locations()->{'localconfig'};
    my $group_id;

    # If we are on Windows, webservergroup does nothing
    if (ON_WINDOWS && $group && $output) {
        print "\n\n" . get_text('install_webservergroup_windows') . "\n\n";
    }

    # If we're not on Windows, make sure that webservergroup isn't
    # empty.
    elsif (!ON_WINDOWS && !$group && $output) {
        print "\n\n" . get_text('install_webservergroup_empty') . "\n\n";
    }

    # If we're not on Windows, make sure we are actually a member of
    # the webservergroup.
    elsif (!ON_WINDOWS && $group) {
        $group_id = getgrnam($group);
        ThrowCodeError('invalid_webservergroup', { group => $group }) 
            unless defined $group_id;

        # If on unix, see if we need to print a warning about a webservergroup
        # that we can't chgrp to
        if ($output && $< != 0 && !grep($_ eq $group_id, split(" ", $)))) {
            print "\n\n" . get_text('install_webservergroup_not_in') . "\n\n";
        }
    }

    return $group_id;
}

1;

__END__

=head1 NAME

Bugzilla::Install::Filesystem - Fix up the filesystem during
  installation.

=head1 DESCRIPTION

This module is used primarily by L<checksetup.pl> to modify the 
filesystem during installation, including creating the data/ directory.

=head1 SUBROUTINES

=over

=item C<update_filesystem({ index_html => 0 })>

Description: Creates all the directories and files that Bugzilla
             needs to function but doesn't ship with. Also does
             any updates to these files as necessary during an
             upgrade.

Params:      C<index_html> - Whether or not we should create
               the F<index.html> file.

Returns:     nothing

=item C<create_htaccess()>

Description: Creates all of the .htaccess files for Apache,
             in the various Bugzilla directories. Also updates
             the .htaccess files if they need updating.

Params:      none

Returns:     nothing

=item C<fix_all_file_permissions($output)>

Description: Sets all the file permissions on all of Bugzilla's files
             to what they should be. Note that permissions are different
             depending on whether or not C<$webservergroup> is set
             in F<localconfig>.

Params:      C<$output> - C<true> if you want this function to print
                 out information about what it's doing.

Returns:     nothing

=back
