#
# WING - Web-IMAP/NNTP Gateway
#
# Wing.pm
#
# Author: Malcolm Beattie, mbeattie@sable.ox.ac.uk
#
# This program may be distributed under the GNU General Public License (GPL)
#
# 25 Aug 1998 Copied from development system to main cluster.
# 23 Feb 1999 Release version 0.5
#
package Wing;
use Apache::Constants qw(:common);
use IO::Socket;
use DBI;
use HTTP::Date; # for time2str
use Wing::Shared;
use Wing::Util;
use strict;
use vars qw($VERSION $dbh);
$VERSION = "0.2";
sub handler {
my $r = shift;
if (!$r) {
Apache->error("null request passed to Wing::handler");
return OK;
} elsif ($r->header_only) {
$r->warn("header_only request for ", $r->path_info);
return OK;
}
# $r->warn("path_info = ", $r->path_info); # debug
my ($loc, $handler, $username, $url_session, $cmd, @args)
= split(m(/), $r->path_info);
#
# Handle requests to kill current logged-in session
#
if ($handler eq "kill") {
return kill_session($r, $username, $url_session);
}
#
# Otherwise, it's an ordinary /wing/cmd/... command
#
my $ip = $r->connection->remote_ip;
my $conn = bless { request => $r }, "Wing::Connection";
# $r->warn("Cookie: ", $r->header_in("Cookie")); # debug
my %sessions = split(/[;=]/, $r->header_in("Cookie"));
my $session = $sessions{$username};
# $r->warn("session for username $username is $session"); # debug
my $server_url = server_url($r);
$conn->{url_prefix} = "$server_url/wing/cmd/$username/";
if (!$session) {
$session = $url_session;
$conn->{url_prefix} .= $session;
}
#
# If we're checking whether cookies work, bounce now to the init
# command which sets a few things up with maild and then redirects
# to list the current folder.
#
if ($cmd eq "check-cookie") {
return redirect($r, "$conn->{url_prefix}/init/$args[0]");
}
#
# Sanity-check username and session identifier
#
if (length($username) > 8 || $username =~ /\W/
|| length($session) != 24 || $session =~ /[^A-Za-z0-9.-]/)
{
return wing_error($r, "Bad session identifier or username.");
}
$conn->{session} = $session;
my $sockname = make_session_socket($username, $session);
my $s = IO::Socket->new(Domain => AF_UNIX,
Type => SOCK_STREAM,
Peer => $sockname);
if (!defined($s)) {
#
# Forcibly expire bad cookie so the browser won't keep sending it
#
my $exp = time2str(time - 1);
$r->header_out("Set-Cookie" =>
make_wing_cookie($username, $session, $exp));
my ($host, $path_info) = login_url($username);
my $login_url = server_url($r, $host) . $path_info;
return wing_error($r, <<"EOT");
Session does not exist (timed out perhaps?).
Please click here to login again.
EOT
}
$conn->{maild} = $s;
#
# Sanity-check command. Note in particular that only methods
# beginning with lower-case a-z are passed on and the actual
# method name called is prefixed with "cmd_".
#
if (length($cmd) > 64 || $cmd !~ /^[a-z]\w*$/) {
return wing_error($r, "Bad command: $cmd");
}
$cmd = "cmd_$cmd";
#
# Check whether session corresponds to this host
#
print $s "check_client_ip $ip\n";
chomp(my $reply = <$s>);
if ($reply ne "OK") {
return wing_error($r, "Security alert: this session did not login "
."from this IP address. Please login properly.");
}
#
# Before handling the command, register a cleanup to close the
# maild socket. This is because $r->print and $r->read both
# implicitly set *hard* timeouts rather than soft ones. That means
# that if we lose the connection to the client (e.g. the client
# hits "Stop" on their browser) then we still want to close the
# maild socket before Apache longjmps back to its main handler.
# If this isn't done, the socket to maild remains open and maild
# (which is single-threaded for each httpd connection) just hangs
# rather than responding to new httpd connections.
#
$r->register_cleanup(sub { $s->close }); # closure
#
# Now handle the requested command
#
eval { $conn->$cmd(@args) };
if ($@ =~ /^Can't locate object method/) {
return wing_error($r, qq(Unknown command "$cmd" sent to Wing: $@));
}
elsif ($@) {
return wing_error($r, "Command error: message is\n
\n$@\n
");
}
# $s->close happens in cleanup registered above
return OK;
}
sub kill_session {
my ($r, $username, $session) = @_;
my $pid = 0;
#
# Sanity-check username and session identifier
#
if (length($username) > 8 || $username =~ /\W/
|| length($session) != 24 || $session =~ /[^A-Za-z0-9.-]/)
{
return wing_error($r, "Bad session identifier or username.");
}
# $r->warn("PID $$ kill_session connecting to database for $username");#debug
my $dbh = DBI->connect(@WING_DBI_CONNECT_ARGS);
$dbh->{AutoCommit} = 0;
my $server = $r->server->server_hostname;
my $sth = $dbh->prepare(
"select pid from sessions where username = '$username' "
." and id = '$session' and server = '$server'"
);
if ($sth) {
$dbh->do("lock table sessions");
if ($sth->execute) {
my $row = $sth->fetchrow_arrayref;
$pid = $row->[0] if $row;
}
$sth->finish;
}
if ($pid) {
#
# OK, zap the session
#
$dbh->do("delete from sessions where username = '$username'");
$dbh->commit;
$dbh->disconnect;
# $r->warn("PID $$ kill_session disconnected from database after session zap");#debug
unlink(make_session_socket($username, $session));
kill("TERM", $pid);
my ($host, $path_info) = login_url($username);
my $login_url = server_url($r, $host) . $path_info;
$r->content_type("text/html");
$r->send_http_header;
$r->print(<<"EOT");
WING session killed
Your orphaned session has been killed. Please click
here to login again.
EOT
} else {
$dbh->commit;
$dbh->disconnect;
# $r->warn("PID $$ kill_session disconnected from database after failed auth");#debug
$r->content_type("text/html");
$r->send_http_header;
$r->print(<<"EOT");
WING error
The server failed to authenticate you or find your orphaned session.
EOT
}
return OK;
}
package Wing::Connection;
use Apache::Constants qw(:common);
use Wing::Shared;
use Wing::Util;
use Fcntl;
use DBI;
use IO::File;
use Socket;
use CrackLib; # for FascistCheck of proposed passwords
use MIME::Base64; # for decode_base64 in decode_body_in_place
use HTTP::Date; # for time2str in cmd_logout
use SQL;
use IO::Handle;
use Mail::Header;
sub _CHUNK_SIZE () { 16384 } # the chunk size in which we read upload data
sub _receive_upload {
my ($r, $filename) = @_;
my $fh = IO::File->new(">$filename") or return "$filename: $!";
my $field = Mail::Field->new("Content-Type");
$field->parse($r->header_in("Content-Type"));
my $boundary = $field->param("boundary");
my $size = $r->header_in("Content-Length");
my $count = $size;
my $buffer;
my ($client_filename, $type);
if ($count > $UPLOAD_SIZE_LIMIT) {
return "upload of $count bytes exceeds limit ($UPLOAD_SIZE_LIMIT bytes)";
}
# $r->warn("_receive_upload: client is sending us $count bytes");#debug
do {
my $toread = _CHUNK_SIZE;
if ($toread > $count) {
$toread = $count;
}
# $r->warn("_receive_upload: trying to read $toread bytes"); # debug
$buffer = ""; # must reset $buffer or Apache::read appends to it
my $didread = $r->read($buffer, $toread);
# $r->warn("_receive_upload: read $didread bytes"); # debug
if ($didread == 0) {
$fh->close;
return "unexpected end of data";
}
if ($count == $size) {
#
# first buffer of all: remove the MIME boundary after checking it
# and then parse and remove the headers.
#
if (substr($buffer, 0, length($boundary)+4) ne "--$boundary\r\n") {
$fh->close;
return "broken MIME boundary marker at start";
}
substr($buffer, 0, length($boundary) + 4) = "";
if ($buffer !~ s/^(.*?\r\n\r\n)//s) {
$fh->close;
return "missing headers";
}
my $headers = $1;
my $deb_headers = $headers;
$deb_headers =~ s/\r/\\r/gs;
$deb_headers =~ s/\n/\\n/gs;
# $r->warn("_receive_upload: MIME headers: $deb_headers");
my $head = Mail::Header->new([split(/\r\n/, $headers)]);
my $disp = $head->get("Content-Disposition");
$client_filename = Mail::Field->new("Content-Disposition",
$disp)->filename;
$type = $head->get("Content-Type");
# $r->warn("_receive_upload: disp=$disp, client_filename=$client_filename, type=$type, size=$size");#debug
}
# $r->warn("_receive_upload: writing ", length($buffer), " bytes");#debug
print $fh $buffer;
$count -= $didread;
} while ($count > 0);
#
# check the trailing MIME boundary is OK
#
my $endlen = length($boundary) + 8;
if (substr($buffer, -$endlen) ne "\r\n--$boundary--\r\n") {
return "broken MIME boundary marker at end";
}
$fh->flush;
my $filesize = (stat($fh))[7];
# $r->warn("_receive_upload: filesize $filesize, truncating $endlen bytes to make ", $filesize - $endlen, " bytes");#debug
truncate($fh, $filesize - $endlen);
$fh->close;
$type =~ tr/\r\n//d;
return ("", $type, $client_filename);
}
sub _replace_body ($$$) {
my $r = shift;
my $s = shift;
print $s "tmpdir\n";
chomp(my $body_file = <$s>);
$body_file .= "/body";
local(*BODY);
if (!sysopen(BODY, $body_file, O_RDWR|O_CREAT|O_TRUNC, 0600)) {
my $err = $!;
$r->content_type("text/plain");
$r->send_http_header;
$r->print("failed to open body file: $err");
$r->warn("failed to open body file: $err");
return 0;
}
print BODY $_[0];
close(BODY);
return 1;
}
sub cmd_init {
my ($conn, $sess_type) = @_;
my $r = $conn->{request};
my $s = $conn->{maild};
print $s "username\n";
chomp(my $username = <$s>);
# $r->warn("PID $$ cmd_init connecting to database for $username");#debug
sql_connect(@WING_DBI_CONNECT_ARGS);
sql_select(["groups.name" => \my $group], [sender => \my $sender],
from => "users, groups",
username => $username, "and users.gid = groups.gid");
sql_fetch
or return wing_error($r, "Can't find group or sender: $DBI::errstr");
maild_set($s, "group", $group);
maild_set($s, "sender", $sender);
sql_select([signature => \my $signature],
[abooklist => \my $abook_list],
[composeheaders => \my $compose_headers],
[listsize => \my $list_size],
[copyoutgoing => \my $copy_outgoing],
from => "options",
username => $username);
sql_fetch;
maild_set($s, "signature", $signature) if defined $signature;
maild_set($s, "abook_list", $abook_list) if defined $abook_list;
maild_set($s, "compose_headers", $compose_headers)
if defined $compose_headers;
maild_set($s, "list_size", $list_size) if defined $list_size;
maild_set($s, "copy_outgoing", 1) if $copy_outgoing;
init_abook_ids($s, $username, $group); # Needs database access
sql_disconnect;
# $r->warn("PID $$ cmd_init disconnecting from database");#debug
# maild_set($s, "message", "Welcome $username");
if ($sess_type eq "portal") {
return cmd_portal($conn);
}
return redirect($r, "$conn->{url_prefix}/list/last");
}
sub cmd_list {
my ($conn, $start, $rand) = @_;
my $r = $conn->{request};
dont_cache($r, "text/html");
my $s = $conn->{maild};
my $url_prefix = $conn->{url_prefix};
my $session = $conn->{session};
my $info_msg = info_message_html($s);
maild_set($s, "abook_return", "list");
my $portal = maild_get($s, "portal");
print $s "list $start\n";
chomp(my $folder = <$s>);
chomp(my $position = <$s>);
chomp(my $flags = <$s>);
my ($from, $to, $nmsgs) = split(' ', $position);
my $can_save = $flags =~ /S/;
my $can_delete = $flags =~ /D/;
$r->print("$folder\n\n");
my ($prev_frag, $next_frag, $top_frag, $bottom_frag);
if ($from == 1) {
$prev_frag = '
';
$top_frag = '
';
} else {
$prev_frag = <<"EOT";
EOT
$top_frag = <<"EOT";
EOT
}
if ($to == $nmsgs) {
$next_frag = '
';
$bottom_frag='
';
} else {
$next_frag = <<"EOT";
EOT
$bottom_frag = <<"EOT";
EOT
}
my $links_html = $portal ? "" : <<"EOT";
 |
EOT
my $header = <<"EOT";
 |
$prev_frag |
$next_frag |
$top_frag |
$bottom_frag |
 |
 |
 |
 |
 |
 |
$links_html
 |
EOT
my $message_s = $nmsgs == 1 ? "message" : "messages";
$r->print($header, $info_msg, <<"EOT");
Mailbox `$folder' with $nmsgs $message_s
EOT
while (1) {
chomp(my $msgno = <$s>);
if (!$msgno) {
$r->log_error("list: maild daemon vanished unexpectedly");
last;
}
# $r->warn("list: headers for msgno $msgno");#debug
last if $msgno eq "."; # the proper way to terminate the list
chomp(my $uid = <$s>);
chomp(my $date = <$s>);
chomp(my $display_address = <$s>);
chomp(my $size = <$s>);
chomp(my $flags = <$s>);
chomp(my $subject = <$s>);
$subject ||= "(No subject)"; # (Subjects of "0" deserve to lose :-)
#
# Calculate status:
# N (new) if \Recent set but \Seen not set
# O (old) if neither \Recent nor \Seen set
# " " otherwise
# Append "D" if \Deleted, "A" if \Answered, "F" if \Flagged
#
my %flags = map { $_ => 1 } split(' ', $flags);
#
# Right Hand Side has Save/Reply/Forward buttons (where
# appropriate to the protocol) and a Delete or Undelete
# button, depending on whether the message is undeleted or
# deleted (resp).
#
my ($status, $is_deleted, $rhs);
$is_deleted = $flags{"\\Deleted"};
$status = $is_deleted ? "D" : " ";
if ($can_save) {
$rhs = qq(S\n);
} else {
$rhs = "";
}
$rhs .= <<"EOT";
R
F
EOT
if ($can_delete) {
if ($is_deleted) {
$rhs .= qq(U\n);
} else {
$rhs .= qq(D\n);
}
}
if ($flags{"\\Seen"}) {
$status .= " ";
} elsif ($flags{"\\Recent"}) {
$status .= "N";
} else {
$status .= "O";
}
$status .= $flags{"\\Answered"} ? "A" : " ";
$status .= $flags{"\\Flagged"} ? "F" : " ";
$r->print(<<"EOT");
| $status |
$msgno. |
$date |
$display_address |
$size |
$subject |
$rhs |
EOT
}
# $r->print("
\n", $header, "\n");
# Maybe better without the header across the bottom too
$r->print("\n", "