#
# Copyright (c) 2006, 2007 Michael Schroeder, Novell Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program (see the file COPYING); if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
#
################################################################
#
# Run a HTTP query operation. Single thread only.
#

package BSRPC;

use Socket;
use XML::Structured;
use Symbol;
use MIME::Base64;
use Data::Dumper;

use BSHTTP;

use strict;

our $useragent = 'BSRPC 0.9.1';

my %hostlookupcache;
my %cookiestore;	# our session store to keep iChain fast
my $tossl;

my $noproxy;

sub import {
  if (grep {$_ eq ':https'} @_) {
    require BSSSL;
    $tossl = \&BSSSL::tossl;
  }
}


my $tcpproto = getprotobyname('tcp');

sub urlencode {
  my $url = $_[0];
  $url =~ s/([\000-\040<>;\"#\?&\+=%[\177-\377])/sprintf("%%%02X",ord($1))/sge;
  return $url;
}

sub createuri {
  my ($param, @args) = @_;
  my $uri = $param->{'uri'};
  if (!$param->{'verbatim_uri'} && $uri =~ /^(https?:\/\/[^\/]*\/)(.*)$/s) {
    $uri = $1; 
    $uri .= BSRPC::urlencode($2);
  }
  if (@args) {
    for (@args) {
      $_ = urlencode($_);
      s/%3D/=/;	# convert first now escaped '=' back
    }   
    if ($uri =~ /\?/) {
      $uri .= '&'.join('&', @args); 
    } else {
      $uri .= '?'.join('&', @args); 
    }   
  }
  return $uri;
}

sub useproxy {
  my ($host, $noproxy) = @_;

  # strip leading and tailing whitespace
  $noproxy =~ s/^\s+//;
  $noproxy =~ s/\s+$//;
  # noproxy is a list separated by commas and optional whitespace
  for (split(/\s*,\s*/, $noproxy)) {
    return 0 if $host =~ m/(^|\.)$_$/;
  }
  return 1;
}

sub createreq {
  my ($param, $uri, $proxy, $cookiestore, @xhdrs) = @_;

  my $act = $param->{'request'} || 'GET';
  if (exists($param->{'socket'})) {
    my $req = "$act $uri HTTP/1.1\r\n".join("\r\n", @xhdrs)."\r\n\r\n";
    return ('', undef, undef, $req, undef);
  }
  my ($proxyauth, $proxytunnel);
  die("bad uri: $uri\n") unless $uri =~ /^(https?):\/\/(?:([^\/\@]*)\@)?([^\/:]+)(:\d+)?(\/.*)$/;
  my ($proto, $auth, $host, $port, $path) = ($1, $2, $3, $4, $5);
  my $hostport = $port ? "$host$port" : $host;
  undef $proxy if $proxy && defined($noproxy) && !useproxy($host, $noproxy);
  if ($proxy) {
    die("bad proxy uri: $proxy\n") unless "$proxy/" =~ /^(https?):\/\/(?:([^\/\@]*)\@)?([^\/:]+)(:\d+)?(\/.*)$/;
    ($proto, $proxyauth, $host, $port) = ($1, $2, $3, $4);
    $path = $uri unless $uri =~ /^https:/;
  }
  $port = substr($port || ($proto eq 'http' ? ":80" : ":443"), 1);
  unshift @xhdrs, "Connection: close" unless $param->{'noclose'};
  unshift @xhdrs, "User-Agent: $useragent" unless !defined($useragent) || grep {/^user-agent:/si} @xhdrs;
  unshift @xhdrs, "Host: $hostport" unless grep {/^host:/si} @xhdrs;
  if (defined $auth) {
    $auth =~ s/%([a-fA-F0-9]{2})/chr(hex($1))/ge;
    unshift @xhdrs, "Authorization: Basic ".encode_base64($auth, '');
  }
  if (defined $proxyauth) {
    $proxyauth =~ s/%([a-fA-F0-9]{2})/chr(hex($1))/ge;
    unshift @xhdrs, "Proxy-Authorization: Basic ".encode_base64($proxyauth, '');
  }
  if ($proxy && $uri =~ /^https/) {
    if ($hostport =~ /:\d+$/) {
      $proxytunnel = "CONNECT $hostport HTTP/1.1\r\nHost: $hostport\r\n";
    } else {
      $proxytunnel = "CONNECT $hostport:443 HTTP/1.1\r\nHost: $hostport:443\r\n";
    }
    $proxytunnel .= shift(@xhdrs)."\r\n" if defined $proxyauth;
    $proxytunnel .= "\r\n";
  }
  if ($cookiestore && %$cookiestore) {
    if ($uri =~ /((:?https?):\/\/(?:([^\/]*)\@)?(?:[^\/:]+)(?::\d+)?)(?:\/.*)$/) {
      push @xhdrs, map {"Cookie: $_"} @{$cookiestore->{$1} || []};
    }
  }
  my $req = "$act $path HTTP/1.1\r\n".join("\r\n", @xhdrs)."\r\n\r\n";
  return ($proto, $host, $port, $req, $proxytunnel);
}

#
# handled paramters:
# timeout
# uri
# data
# headers (array)
# chunked
# request
# verbatim_uri
# socket
# https
# continuation
# verbose
# sender
# async
# replyheaders
# receiver
# ignorestatus
# receiverarg
# maxredirects
# proxy
#

sub rpc {
  my ($uri, $xmlargs, @args) = @_;

  my $data = '';
  my @xhdrs;
  my $chunked;
  my $param = {'uri' => $uri};

  if (ref($uri) eq 'HASH') {
    $param = $uri;
    my $timeout = $param->{'timeout'};
    if ($timeout) {
      my %paramcopy = %$param;
      delete $paramcopy{'timeout'};
      my $ans;
      local $SIG{'ALRM'} = sub {alarm(0); die("rpc timeout\n");};
      eval {
        eval {
          alarm($timeout);
          $ans = rpc(\%paramcopy, $xmlargs, @args);
        };
        alarm(0);
        die($@) if $@;
      };
      die($@) if $@;
      return $ans;
    }
    $uri = $param->{'uri'};
    $data = $param->{'data'};
    @xhdrs = @{$param->{'headers'} || []};
    $chunked = 1 if $param->{'chunked'};
    if (!defined($data) && $param->{'request'} && $param->{'request'} eq 'POST' && @args && grep {/^content-type:\sapplication\/x-www-form-urlencoded$/i} @xhdrs) {
      for (@args) {
	$_ = urlencode($_);
        s/%3D/=/;	# convert now escaped = back
      }
      $data = join('&', @args);
      @args = ();
    }
    push @xhdrs, "Content-Length: ".length($data) if defined($data) && !ref($data) && !$chunked && !grep {/^content-length:/i} @xhdrs;
    push @xhdrs, "Transfer-Encoding: chunked" if $chunked;
    $data = '' unless defined $data;
  }
  $uri = createuri($param, @args);
  my $proxy = $param->{'proxy'};
  my ($proto, $host, $port, $req, $proxytunnel) = createreq($param, $uri, $proxy, \%cookiestore, @xhdrs);
  if ($proto eq 'https' || $proxytunnel) {
    die("https not supported\n") unless $tossl || $param->{'https'};
  }
  local *S;
  if (exists($param->{'socket'})) {
    *S = $param->{'socket'};
  } else {
    if (!$hostlookupcache{$host}) {
      my $hostaddr = inet_aton($host);
      die("unknown host '$host'\n") unless $hostaddr;
      $hostlookupcache{$host} = $hostaddr;
    }
    socket(S, PF_INET, SOCK_STREAM, $tcpproto) || die("socket: $!\n");
    setsockopt(S, SOL_SOCKET, SO_KEEPALIVE, pack("l",1));
    connect(S, sockaddr_in($port, $hostlookupcache{$host})) || die("connect to $host:$port: $!\n");
    if ($proxytunnel) {
      BSHTTP::swrite(\*S, $proxytunnel);
      my $ans = '';
      do {
	die("received truncated answer\n") if !sysread(S, $ans, 1024, length($ans));
      } while ($ans !~ /\n\r?\n/s);
      die("bad answer\n") unless $ans =~ s/^HTTP\/\d+?\.\d+?\s+?(\d+[^\r\n]*)/Status: $1/s;
      my $status = $1;
      die("proxy tunnel: CONNECT method failed: $status\n") unless $status =~ /^200[^\d]/;
    }
    ($param->{'https'} || $tossl)->(\*S, $param->{'ssl_keyfile'}, $param->{'ssl_certfile'}, 1) if $proto eq 'https' || $proxytunnel;
  }
  if (!$param->{'continuation'}) {
    if ($param->{'verbose'}) {
      print "> $_\n" for split("\r\n", $req);
      #print "> $data\n" unless ref($data);
    }
    $req .= "$data" unless ref($data);
    if ($param->{'sender'}) {
      $param->{'sender'}->($param, \*S, $req);
    } else {
      while(1) {
	BSHTTP::swrite(\*S, $req);
	last unless ref $data;
	$req = &$data($param, \*S);
	if (!defined($req) || !length($req)) {
	  $req = $data = '';
	  $req = "0\r\n\r\n" if $chunked;
	  next;
	}
	$req = sprintf("%X\r\n", length($req)).$req."\r\n" if $chunked;
      }
    }
    if ($param->{'async'}) {
      my $ret = {};
      $ret->{'uri'} = $uri;
      my $fd = gensym;
      *$fd = \*S;
      $ret->{'socket'} = $fd;
      $ret->{'async'} = 1;
      $ret->{'continuation'} = 1;
      $ret->{'request'} = $param->{'request'} || 'GET';
      $ret->{'verbose'} = $param->{'verbose'} if $param->{'verbose'};
      $ret->{'replyheaders'} = $param->{'replyheaders'} if $param->{'replyheaders'};
      $ret->{'receiver'} = $param->{'receiver'} if $param->{'receiver'};
      $ret->{$_} = $param->{$_} for grep {/^receiver:/} keys %$param;
      $ret->{'receiverarg'} = $xmlargs if $xmlargs;
      return $ret;
    }
  }
  my $ans = '';
  do {
    die("received truncated answer\n") if !sysread(S, $ans, 1024, length($ans));
  } while ($ans !~ /\n\r?\n/s);
  die("bad answer\n") unless $ans =~ s/^HTTP\/\d+?\.\d+?\s+?(\d+[^\r\n]*)/Status: $1/s;
  my $status = $1;
  $ans =~ /^(.*?)\n\r?\n(.*)$/s;
  my $headers = $1;
  $ans = $2;
  if ($param->{'verbose'}) {
    print "< $_\n" for split(/\r?\n/, $headers);
  }
  my %headers;
  BSHTTP::gethead(\%headers, $headers);
  if ($status =~ /^200[^\d]/) {
    undef $status;
  } elsif ($status =~ /^302[^\d]/) {
    # XXX: should we do the redirect if $param->{'ignorestatus'} is defined?
    close S;
    die("error: no redirects allowed\n") unless defined $param->{'maxredirects'};
    die("error: status 302 but no 'location' header found\n") unless exists $headers{'location'};
    die("error: max number of redirects reached\n") if $param->{'maxredirects'} < 1;
    my %myparam = %$param;
    $myparam{'uri'} = $headers{'location'};
    $myparam{'maxredirects'} = $param->{'maxredirects'} - 1;
    return rpc(\%myparam, $xmlargs, @args);
  } else {
    #if ($param->{'verbose'}) {
    #  1 while sysread(S, $ans, 1024, length($ans));
    #  print "< $ans\n";
    #}
    if ($status =~ /^(\d+) +(.*?)$/) {
      die("$1 remote error: $2\n") unless $param->{'ignorestatus'};
    } else {
      die("remote error: $status\n") unless $param->{'ignorestatus'};
    }
  }
  if ($headers{'set-cookie'} && $param->{'uri'}) {
    my @cookie = split(',', $headers{'set-cookie'});
    s/;.*// for @cookie;
    if ($param->{'uri'} =~ /((:?https?):\/\/(?:([^\/]*)\@)?(?:[^\/:]+)(?::\d+)?)(?:\/.*)$/) {
      my %cookie = map {$_ => 1} @cookie;
      push @cookie, grep {!$cookie{$_}} @{$cookiestore{$1} || []};
      splice(@cookie, 10) if @cookie > 10;
      $cookiestore{$1} = \@cookie;
    }
  }
  if (($param->{'request'} || '') eq 'HEAD') {
    close S;
    ${$param->{'replyheaders'}} = \%headers if $param->{'replyheaders'};
    return \%headers;
  }
  $headers{'__socket'} = \*S;
  $headers{'__data'} = $ans;
  my $receiver;
  $receiver = $param->{'receiver:'.lc($headers{'content-type'} || '')};
  $receiver ||= $param->{'receiver'};
  $xmlargs ||= $param->{'receiverarg'};
  if ($receiver) {
    $ans = $receiver->(\%headers, $param, $xmlargs);
    $xmlargs = undef;
  } else {
    $ans = BSHTTP::read_data(\%headers, undef, 1);
  }
  close S;
  delete $headers{'__socket'};
  delete $headers{'__data'};
  ${$param->{'replyheaders'}} = \%headers if $param->{'replyheaders'};
  #if ($param->{'verbose'}) {
  #  print "< $ans\n";
  #}
  if ($xmlargs) {
    die("answer is not xml\n") if $ans !~ /<.*?>/s;
    my $res = XMLin($xmlargs, $ans);
    return $res;
  }
  return $ans;
}

1;
