#!/usr/bin/perl
# Copyright (c) 2003 Gavin Brown. All rights reserved. This program is free
# software; you can redistribute it and/or modify it under the same terms
# as Perl itself.
#
# $Id: aegis-virus-scanner.pl,v 1.12 2004/12/14 16:07:01 jodrell Exp $
use Gtk2 -init;
use Gtk2::Helper;
use Gtk2::GladeXML;
use Gtk2::SimpleList;
use File::Scan;
use File::Basename qw(dirname basename);
use vars qw($VERSION);
use POSIX qw(strftime);
use Archive::Zip;
use Archive::Tar;
use MIME::Base64;
use URI::Escape;
use Locale::gettext;
use Gnome2::VFS;
use POSIX;
use strict;

my $NAME		= 'Aegis';
my $SNAME		= 'aegis-virus-scanner';
our $VERSION		= '0.1.1';
my $LOGFILE		= sprintf('%s/%s.log', $ENV{HOME}, lc($NAME));
my $QUARANTINE		= sprintf('%s/QUARANTINE', $ENV{HOME});
my $RCFILE		= sprintf('%s/.%src', $ENV{HOME}, lc($NAME));
my $PREFIX		= '/usr';
my $GLADE_FILE		= (-d $PREFIX ? "$PREFIX/share/$SNAME"	: $ENV{PWD})."/$SNAME.glade";
my $ICON_FILE		= (-d $PREFIX ? "$PREFIX/share/pixmaps"	: $ENV{PWD})."/$SNAME.png";
my $LOCALE_DIR		= (-d $PREFIX ? "$PREFIX/share/locale"	: $ENV{PWD}.'/locale');
my $STOPPED		= 0;
my $RUNNING		= 0;
my $FM_URL		= 'http://freshmeat.net/projects-xml/filescan/filescan.xml';

setlocale(LC_ALL, $ENV{LANG});

bindtextdomain(lc($NAME), $LOCALE_DIR);
textdomain(lc($NAME));

Gnome2::VFS->init;

my ($DIR, $FILE, $AUTORUN);
if (-d $ARGV[0]) {
	$DIR		= $ARGV[0];
	$AUTORUN	= 1;
} elsif (-e $ARGV[0]) {
	$FILE		= $ARGV[0];
	$DIR		= dirname($FILE);
	$AUTORUN	= 1;
} else {
	$DIR		= $ENV{HOME};
	$AUTORUN	= 0;
}

my $WARNING_DIALOG_FORMAT = <<"END";
<span size="large" weight="bold">%s</span>

%s

END

my %OPTIONS = (
	subs	=> 1,
	dots	=> 1,
	archs	=> 1,
	log	=> 0,
);

load_options();

my @HISTORY		= grep { defined } split(/\:/, $OPTIONS{hist});
my $AGE			= get_filescan_age();
my $AGE_STRING		= ($AGE == 0 ? _('today') : _('{days} days ago', days => $AGE));

my $ABOUT_DIALOG_TEXT = << "END";
<span weight="bold" size="x-large">%s</span>

%s

<span size="small">%s</span>
END

my $normal	= Gtk2::Gdk::Cursor->new('left_ptr');
my $busy	= Gtk2::Gdk::Cursor->new('watch');

my $scanner	= File::Scan->new;
my $app		= Gtk2::GladeXML->new($GLADE_FILE);

my $results_list = Gtk2::SimpleList->new_from_treeview(
	$app->get_widget('results_list'),
	'File'		=> 'text',
	'Details'	=> 'text',
	'Path'		=> 'text',
);
$results_list->get_column(2)->set_visible(0);
$results_list->drag_dest_set(['drop', 'motion', 'highlight'], ['copy', 'private', 'default', 'move', 'link', 'ask']);
$results_list->signal_connect(drag_data_received => \&drop_handler);

my $target_list	= Gtk2::TargetList->new;
$target_list->add(Gtk2::Gdk::Atom->new('text/uri-list'), 0, 0);
$results_list->drag_dest_set_target_list($target_list);

$app->signal_autoconnect_from_package('main');

$app->get_widget('main_window')->set_icon(
	-e $ICON_FILE ?
		Gtk2::Gdk::Pixbuf->new_from_file($ICON_FILE)
	:
		$app->get_widget('main_window')->render_icon('gtk-dialog-info', 'dialog')
);
$app->get_widget('warning_dialog')->set_icon($app->get_widget('main_window')->get_icon);

$app->get_widget('directory_combo')->set_popdown_strings($DIR.'/', @HISTORY);

$app->get_widget('recursive_checkbutton')->set_active($OPTIONS{subs});
$app->get_widget('hidden_checkbutton')->set_active($OPTIONS{dots});
$app->get_widget('archive_checkbutton')->set_active($OPTIONS{archs});
$app->get_widget('log_checkbutton')->set_active($OPTIONS{log});

$app->get_widget('stop_button')->set_sensitive(0);

set_status(_('Ready.'));

if ($AUTORUN == 1) {
	if (-e $FILE) {
		scan_file($FILE);
	} elsif (-d $DIR) {
		$app->get_widget('scan_button')->clicked;
	} 
} else {
	Glib::Timeout->add(500, \&check_for_updates);
}

Gtk2->main;

sub update_ui() {
	Gtk2->main_iteration while (Gtk2->events_pending);
}

sub load_options {
	if (-r $RCFILE) {
		open(RCFILE, $RCFILE);
		while (<RCFILE>) {
			chomp;
			if (/^(\w+)=(.+)$/) {
				$OPTIONS{$1} = $2;
			}
		}
		close(RCFILE);
	}
	return 1;
}

sub set_status {
	my $str = shift;
	$str =~ s!//!/!g;
	$app->get_widget('status_bar')->push($app->get_widget('status_bar')->get_context_id('Normal'), $str);
	return 1;
}

sub log_entry {
	my $str = shift;
	set_status($str);
	printf(LOGFH "%s: %s\n", scalar(localtime()), $str) if ($app->get_widget('log_checkbutton')->get_active);
	return 1;
}

sub save_options {
	$OPTIONS{subs}	= ($app->get_widget('recursive_checkbutton')->get_active ? 1 : 0);
	$OPTIONS{dots}	= ($app->get_widget('hidden_checkbutton')->get_active ? 1 : 0);
	$OPTIONS{archs}	= ($app->get_widget('archive_checkbutton')->get_active ? 1 : 0);
	$OPTIONS{log}	= ($app->get_widget('log_checkbutton')->get_active ? 1 : 0);
	$OPTIONS{hist}	= join(':', @HISTORY);
	if (open(RCFILE, ">$RCFILE")) {
		foreach my $key (sort keys %OPTIONS) {
			printf(RCFILE "%s=%s\n", $key, $OPTIONS{$key});
		}
		close(RCFILE);
	} else {
		return undef;
	}
}

sub get_filescan_age {
	my $pmfile;
	LOOP: foreach my $dir (@INC) {
		my $file = sprintf('%s/File/Scan.pm', $dir);
		if (-e $file) {
			$pmfile = $file;
			last LOOP;
		}
	}
	my $age = -1;
	if (-e $pmfile) {
		my $mtime = (stat($pmfile))[9];
		$age = int((time() - $mtime) / 86400);
	}
	return ($age > 0 ? $age : undef);
}

sub drop_handler {
	my @uris = split(/\n+/, $_[4]->data);
	foreach my $uri (@uris) {
		if ($uri !~ /^file:/i) {
			my $dialog = Gtk2::MessageDialog->new($app->get_widget('main_window'), 'modal', 'error', 'ok', _("VFS Scheme not supported."));
			$dialog->set_icon($app->get_widget('main_window')->get_icon);
			$dialog->signal_connect('response', sub { $dialog->destroy });
			$dialog->show_all;
			return undef;
		} else {
			my $file = uri_unescape($uri);
			$file =~ s!^file://!!ig;
			$file =~ s/[\r\n]//g;
			if (-d $file) {
				$file .= '/' unless ($file =~ /\/$/);
				$app->get_widget('directory_combo')->entry->set_text($file);
				$app->get_widget('scan_button')->clicked;
			} elsif (-e $file) {
				scan_file($file);
			}
		}
	}
	return 1;
}

sub on_main_window_delete_event {
	save_options();
	exit;
}

sub on_warning_dialog_delete_event {
	$app->get_widget('warning_dialog')->hide_all;
	return 1;
}

sub on_warning_dialog_response {
	$app->get_widget('warning_dialog')->hide_all;
	return undef;
}

sub on_browse_button_clicked {
	my $selection;
	if ('' ne (my $msg = Gtk2->check_version (2, 4, 0)) or $Gtk2::VERSION < 1.040) {
		$selection = Gtk2::FileSelection->new(_('Select Directory'));
	} else {
		$selection = Gtk2::FileChooserDialog->new(
			_('Select Directory'),
			$app->get_widget('main_window'),
			'select-folder',
			'gtk-cancel'	=> 'cancel',
			'gtk-ok' => 'ok'
		);
	}
	$selection->set_icon($app->get_widget('main_window')->get_icon);
	$selection->set_filename($app->get_widget('directory_combo')->entry->get_text);
	$selection->signal_connect('response', sub {
		if ($_[1] eq 'ok') {
			my $dir = $selection->get_filename;
			$dir = (-d $dir ? $dir : dirname($dir)) . '/';
			$app->get_widget('directory_combo')->entry->set_text($dir);
		}
		$selection->destroy;
	});
	$selection->show_all;
	return 1;
}

sub on_about_button_clicked {
	$app->get_widget('about_dialog')->set_icon($app->get_widget('main_window')->get_icon);
	$app->get_widget('about_dialog_image')->set_from_pixbuf($app->get_widget('main_window')->get_icon);
	$app->get_widget('about_dialog_label')->set_markup(sprintf(
		$ABOUT_DIALOG_TEXT,
		_('{name} Virus Scanner version {version}', name => $NAME, version => $VERSION),
		_('Author: Gavin Brown &lt;gavin.brown@uk.com&gt;'),
		_('File::Scan {version}, last updated {age}', version => $File::Scan::VERSION, age => $AGE_STRING),
	));
	$app->get_widget('about_dialog')->show_all;
}

sub on_update_button_clicked {
	my $cmd	= 'gnome-terminal --disable-factory -t "'._('Updating File::Scan...').'" -x su -c "perl -MCPAN -e \'install File::Scan\'"';
	exec_wait($cmd, sub {
		my $dialog = Gtk2::MessageDialog->new(
			$app->get_widget('main_window'),
			'modal', 'info', 'ok',
			_('Update complete. Please restart {name}.', name => $NAME)
		);
		$dialog->signal_connect('response', sub { $dialog->destroy });
		$dialog->show_all;
	});
	return 1;
}

sub on_about_dialog_response {
	$app->get_widget('about_dialog')->hide_all;
	return 1;
}

sub on_stop_button_clicked {
	$STOPPED = 1;
	return 1;
}

sub on_scan_button_clicked {
	my $dir = $app->get_widget('directory_combo')->entry->get_text;
	start_scan($dir);
	return 1;
}

sub start_scan {
	my $dir = shift;
	$STOPPED = 0;
	$RUNNING = 1;

	@{$results_list->{data}} = ();

	open(LOGFH, ">>$LOGFILE") if ($app->get_widget('log_checkbutton')->get_active);

	log_entry("Started.");

	$app->get_widget('stop_button')->set_sensitive(1);

	$app->get_widget('scan_button')->set_sensitive(0);
	$app->get_widget('recursive_checkbutton')->set_sensitive(0);
	$app->get_widget('hidden_checkbutton')->set_sensitive(0);
	$app->get_widget('archive_checkbutton')->set_sensitive(0);
	$app->get_widget('log_checkbutton')->set_sensitive(0);
	$app->get_widget('browse_button')->set_sensitive(0);

	$app->get_widget('main_window')->window->set_cursor($busy);

	scan_dir($dir);

	end_scan();

	return 1;
}

sub stop_scan {
	close(LOGFH);

	end_scan();

	log_entry(_('Stopped.'));

	return 1;
}

sub end_scan {

	$RUNNING = 0;

	$app->get_widget('stop_button')->set_sensitive(0);

	$app->get_widget('scan_button')->set_sensitive(1);
	$app->get_widget('recursive_checkbutton')->set_sensitive(1);
	$app->get_widget('hidden_checkbutton')->set_sensitive(1);
	$app->get_widget('archive_checkbutton')->set_sensitive(1);
	$app->get_widget('log_checkbutton')->set_sensitive(1);
	$app->get_widget('browse_button')->set_sensitive(1);

	$app->get_widget('main_window')->window->set_cursor($normal);

	return 1;

}

sub scan_dir {
	my $dir = shift;
	return undef if ($dir eq $QUARANTINE);
	log_entry(_('Scanning {dir}', dir=> $dir));
	update_ui();
	if ($STOPPED == 1) {
		stop_scan();
		return undef;
	}
	if (opendir(DIR, $dir)) {
		my @nodes = grep { !/^\.{1,2}$/ } readdir(DIR);
		closedir(DIR);
		my (@files, @dirs);
		foreach my $node (sort @nodes) {
			next if ($node =~ /^\./ && !$app->get_widget('hidden_checkbutton')->get_active);
			if (-l "$dir$node") {
				log_entry(_('{node} is a symbolic link, ignoring', node => "$dir$node"));
			} elsif (-d "$dir$node") {
				push(@dirs, $node);
			} else {
				push(@files, $node);
			}
		}
		foreach my $node (@files) {
			if ($STOPPED == 1) {
				stop_scan();
				return undef;
			} else {
				scan_file("$dir$node");
			}
		}
		if ($app->get_widget('recursive_checkbutton')->get_active) {
			foreach my $node (@dirs) {
				if ($STOPPED == 1) {
					stop_scan();
					return undef;
				} else {
					scan_dir("$dir$node/");
				}
			}
		}
		log_entry(_('Scan of {dir} complete.', dir => $dir));
	} else {
		log_entry(_("Error: can't open {dir}: {error}", dir => $dir, error => $!));
		return undef;
	}
	return 1;
}

sub scan_file {
	my $file = shift;
	log_entry(_('Scanning {file}', file => $file));
	update_ui();
	my $virus;
	if (is_archive($file) && $app->get_widget('archive_checkbutton')->get_active) {
		$virus = scan_archive($file);
	} else {
		$virus = $scanner->scan($file);
	}
	if ($virus ne '') {
		push(@{$results_list->{data}}, [
			basename($file),
			$virus,
			$file,
		]);
		my $dialog = Gtk2::MessageDialog->new_with_markup(
			$app->get_widget('main_window'),
			'modal', 'warning',
			'ok',
			sprintf(
				$WARNING_DIALOG_FORMAT,
				_('File is infected!'),
				_('The file {file} is infected with the <span weight="bold">{virus}</span> virus!',
					file => $file,
					virus => $virus
				),
			),
		);
		$dialog->signal_connect('response', sub {
			$dialog->destroy;
		});
		$dialog->show_all;
	}

	return 1;
}

sub is_archive {
	return ($_[0] =~ /\.(tar|tar\.gz|zip)$/i ? 1 : undef);
}

sub scan_archive {
	my $archive_file = shift;
	my $archive = new_archive($archive_file);
	foreach my $file (get_archive_contents($archive)) {
		return undef if ($STOPPED == 1);
		set_status(_('Scanning archive {archive}:{file}', archive => basename($archive_file), file => $file));
		update_ui();
		chomp(my $tmpfile = sprintf('/tmp/%s-%s-%s', lc($NAME), (getpwuid($<))[0], encode_base64($file)));
		extract_file_from_archive($archive, $file, $tmpfile);
		my $virus = $scanner->scan($tmpfile);
		my $cmd = sprintf('rm -rf "%s"', quotemeta($tmpfile));
		system($cmd);
		if ($? != 0) {
			printf(STDERR "Error: command '%s' failed: returned error code %d\n", $cmd, $?);
		}
		return $virus if ($virus ne '');
	}
}

# you may be wondering why I'm not using Archive::Any.
# I have tried a couple of times to get this module to
# work and each time have failed. various things go
# wrong with it.
# so this is a kludge. maybe when Archive::Any works
# better I will support it.
sub new_archive {
	my $file = shift;
	if ($file =~ /\.(tar|tar\.gz|tar\.Z)$/i) {
		return Archive::Tar->new($file);
	} elsif ($file =~ /\.zip$/i) {
		return Archive::Zip->new($file);
	}
	return undef;
}

sub get_archive_contents {
	my $archive = shift;
	if (ref($archive) eq 'Archive::Tar') {
		return $archive->list_files;
	} elsif (ref($archive) =~ /^Archive::Zip/i) {
		return $archive->memberNames;
	}
	return undef;
}

sub extract_file_from_archive {
	my ($archive, $file, $to) = @_;
	if (ref($archive) eq 'Archive::Tar') {
		open(FILE, ">$to") or return undef;
		print FILE $archive->get_content($file);
		close(FILE);
		return 1;
	} elsif (ref($archive) =~ /^Archive::Zip/i) {
		$archive->extractMember($file, $to);
		return 1;
	}
	return undef;
}

sub on_results_list_clicked {
	return undef unless ($_[1]->button == 3);
	return undef if ($RUNNING == 1);
	my $idx = ($results_list->get_selected_indices)[0];
	return undef unless (defined($idx));

	my @data = (
		[
			'/',
			undef,
			undef,
			undef,
			'<Branch>',
		],
		[
			_('/_Delete'),
			undef,
			\&delete_selected,
			undef,
			'<StockItem>',
			'gtk-delete',
		],
		[
			_('/_Rename'),
			undef,
			\&rename_selected,
			undef,
			'<StockItem>',
			'gtk-copy',
		],
		[
			_('/Q_uarantine'),
			undef,
			\&quarantine_selected,
			undef,
			'<StockItem>',
			'gtk-dnd-multiple',
		],
		[
			_('/_Information'),
			undef,
			\&about_selected,
			undef,
			'<StockItem>',
			'gtk-dialog-info',
		],
	);
	my $factory = Gtk2::ItemFactory->new('Gtk2::Menu', '<main>', undef);
	$factory->create_items(@data);
	my $menu = $factory->get_widget('<main>');
	$menu->popup(undef, undef, undef, 0, undef, undef);

	return 1;
}

sub delete_selected {
	my $idx = ($results_list->get_selected_indices)[0];
	my $file = $results_list->{data}[$idx][2];
	unlink($file);
	log_entry(_('Deleted {file}', file => $file));
	$results_list->{data}[$idx][1] = _('DELETED');
	return 1;
}

sub rename_selected {
	my $idx = ($results_list->get_selected_indices)[0];
	my $file = $results_list->{data}[$idx][2];
	my $dest = sprintf('%s.VIRUS', $file);
	rename($file, $dest);
	chmod(0400, $dest);
	log_entry(_('Renamed {file}', file => $file));
	$results_list->{data}[$idx][1] = _('Renamed to {file}', file => $dest);
	return 1;
}

sub quarantine_selected {
	my $idx = ($results_list->get_selected_indices)[0];
	my $file = $results_list->{data}[$idx][2];
	mkdir($QUARANTINE);
	my $dest = sprintf('%s/%s', $QUARANTINE, basename($file));
	if (rename($file, $dest)) {
		chmod(0400, $dest);
		log_entry(_('Quarantined {file}', file => $file));
		$results_list->{data}[$idx][1] = _('Quarantined to {file}', file => $dest);
	} else {
		my $dialog = Gtk2::MessageDialog->new($app->get_widget('main_window'), 'modal', 'error', 'ok', _('Quarantine of file failed: {error}', error => $!));
		$dialog->signal_connect('response', sub { $dialog->destroy });
		$dialog->show_all;
	}
	return 1;
}

sub about_selected {
	my $idx = ($results_list->get_selected_indices)[0];
	my $virus = $results_list->{data}[$idx][1];
	my $url = sprintf("http://vil.nai.com/vil/alphar.asp?SearchType=2&action=Search&char=%s", $virus);
	system("gnome-open \"$url\" &");
	return 1;
}

sub check_for_updates {
	$app->get_widget('main_window')->window->set_cursor($busy);
	set_status(_('Checking for updates...'));
	update_ui();
	my $data = Gnome2::VFS->read_entire_file($FM_URL);
	set_status(_('Ready.'));
	$app->get_widget('main_window')->window->set_cursor($normal);
	if ($data =~ /<latest_release_version>(.+)<\/latest_release_version>/i) {
		my $version = $1;
		if ($version > $File::Scan::VERSION) {
			my $dialog = Gtk2::MessageDialog->new_with_markup(
				$app->get_widget('main_window'),
				'modal', 'question',
				'yes-no',
				sprintf(
					"<span weight=\"bold\" size=\"large\">%s</span>\n\n%s",
					_('A new version of File::Scan is available.'),
					_('Do you want to update?'),
				),
			);
			$dialog->signal_connect('response', sub {
				$dialog->destroy;
				if ($_[1] eq 'yes') {
					$app->get_widget('update_button')->clicked;
				}
			});
			$dialog->show_all;
		}
	}
	return undef;
}

sub exec_wait {
	my ($command, $callback) = @_;

	open(COMMAND, "$command|");
	my $tag;
	$tag = Gtk2::Helper->add_watch(fileno(COMMAND), 'in', sub {
		if (eof(COMMAND)) {
			close(COMMAND);
			Gtk2::Helper->remove_watch($tag);
			if (defined($callback)) {
				&$callback();
			}
		}
	});
	return 1;
}

sub _ {
	my $str = shift;
	my %args = @_;
	my $translated = gettext($str);
	foreach my $name (keys %args) {
		$translated =~ s/{$name}/$args{$name}/ig;
	}
	return $translated;
}
