The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package Moxy;
use 5.00800;
use strict;
use warnings;
use base qw/Class::Accessor::Fast/;
use Class::Component 0.16;

our $VERSION = '0.63';

use Carp;
use Encode;
use File::Spec::Functions;
use File::Basename;
use FindBin;
use HTML::Entities;
use HTML::Parser;
use HTML::TreeBuilder::XPath;
use HTML::TreeBuilder;
use HTTP::Cookies;
use HTTP::Session;
use LWP::UserAgent;
use MIME::Base64;
use Moxy::Util;
use Params::Validate ':all';
use Path::Class;
use Scalar::Util qw/blessed/;
use UNIVERSAL::require;
use URI::Escape;
use URI::Heuristic qw(uf_uristr);
use URI;
use YAML;
use Time::HiRes ();
use Plack::Response;
use Moxy::Request;
use HTTP::Message::PSGI;
use File::Temp;
use File::Spec;
use HTTP::MobileAttribute plugins => [
    qw/CarrierLetter IS/,
    {
        module => 'Display',
        config => {
            DoCoMoMap => YAML::LoadFile(
                catfile( 'assets', 'common', 'docomo-display-map.yaml' )
            )
        }
    },
];

__PACKAGE__->load_components(qw/Plaggerize Autocall::InjectMethod Context/);

__PACKAGE__->load_plugins(qw/
    DisplayWidth ControlPanel LocationBar Pictogram
    Status::401 Status::500 Status::404
    UserID XMLisHTML UserAgentSwitcher RefererCutter CookieCutter FlashUseImgTag
    DisableTableTag GPS HTTPHeader QRCode ShowHTTPHeaders
/);
__PACKAGE__->mk_accessors(qw/response_time/);

sub new {
    my ($class, $config) = @_;

    if ( $config->{global}->{plugins} ) {
        $class->load_plugins(@{ $config->{global}->{plugins} });
    }

    $config->{global}->{log}->{level} ||= 'info';

    $config->{global}->{assets_path} ||= do {
        my $libpath = $INC{'Moxy.pm'};
        $libpath =~ s!(?:blib/)?lib/+Moxy\.pm$!!;
        $libpath ||= './';
        $libpath = File::Spec->rel2abs($libpath);
        File::Spec->catdir($libpath, 'assets');
    };

    my $self = $class->NEXT( 'new' => { config => $config } );

    $self->conf->{global}->{session}->{store} ||= +{
        module => 'File',
        config => {
            dir => do {
                my $dir = File::Temp::tempdir('moxyXXXXXX', CLEANUP => 1, DIR => File::Spec->tmpdir);
                $self->{__session} = $dir;
                "$dir", # we need stringify for file::temp
            }
        },
    };

    $self->conf->{global}->{log}->{fh} ||= \*STDERR;

    return $self;
}

sub assets_path { shift->conf->{global}->{assets_path} }

sub res {
    Plack::Response->new(@_);
}
sub HTTP::Response::to_plack_response {
    my $self = shift;
    return res(
        $self->code,
        $self->headers,
        $self->content,
    );
}

# -------------------------------------------------------------------------

sub run_hook_and_get_response {
    my ($self, $hook, @args) = @_;

    $self->log(debug => "Run hook and get response: $hook");
    for my $action (@{$self->class_component_hooks->{$hook}}) {
        my $code = $action->{plugin}->can($action->{method});
        my $response = $code->($action->{plugin}, $self, @args);
        return $response if blessed $response && $response->isa('HTTP::Response');
    }
    return; # not finished yet
}

sub rewrite_css {
    my ($base, $css, $url) = @_;
    my $base_url = URI->new($url);

    $css =~ s{url\(([^\)]+)\)}{
        my $x = $1;
        sprintf "url(%s%s%s)",
            $base,
            ($base =~ m{/$} ? '' : '/'),
            uri_escape( URI->new($x)->abs($base_url) )
    }ge;

    $css;
}

sub rewrite_html {
    my ($base, $html, $url) = @_;

    my $base_url = URI->new($url);

    # parse.
    my $tree = HTML::TreeBuilder::XPath->new;
    $tree->implicit_tags(0);
    $tree->no_space_compacting(1);
    $tree->ignore_ignorable_whitespace(0);
    $tree->store_comments(1);
    $tree->ignore_unknown(0);
    $tree->parse($html);
    $tree->eof;

    # define replacer.
    my $replace = sub {
        my ( $tag, $attr_name ) = @_;

        for my $node ( $tree->findnodes("//$tag") ) {
            if ( my $attr = $node->attr($attr_name) ) {
                next if $attr =~ /^mailto:/;
                if ($attr =~ /^tel:([0-9-]+)$/) {
                    my $tel = $1;
                    $node->attr(
                        'onclick' => qq{prompt('tel', '$1');return false;}
                    );
                } else {
                    # maybe /https?/
                    my $target_url = URI->new($attr);
                       $target_url = $target_url->abs($base_url) if $base_url;
                    $node->attr(
                        $attr_name => sprintf( qq{%s%s%s},
                            $base,
                            ($base =~ m{/$} ? '' : '/'),
                            uri_escape( $target_url ) )
                    );
                }
            }
        }
    };

    # replace.
    $replace->( 'img'    => 'src' );
    $replace->( 'script' => 'src' );
    $replace->( 'form'   => 'action' );
    $replace->( 'a'      => 'href' );
    $replace->( 'link'   => 'href' );

    # dump.
    my $result = $tree->as_HTML(q{<>"&'}, '', {});
    $tree = $tree->delete; # cleanup :-) HTML::TreeBuilder needs this.

    # return result.
    return $result;
}

sub to_app {
    my ($self) = @_;
    sub {
        my $env = shift;
        my $req = Moxy::Request->new($env);
        my $res = $self->handle_request($req);
           $res->content_length( length($res->content) ); # adjust content-length.
           $res->finalize();
    };
}

sub handle_request {
    my ($self, $req) = @_;

    $self->log(debug => "---------------------------");

    my $conf = $self->conf->{global}->{session};
    my $state_type = $conf->{state}->{module} || 'BasicAuth';
    my $state = sub {
        if ($state_type eq 'Cookie') {
            require HTTP::Session::State::Cookie;
            HTTP::Session::State::Cookie->new(
                $conf->{state}->{config}
            );
        } else {
            require Moxy::Session::State::BasicAuth;
            Moxy::Session::State::BasicAuth->new(
                $conf->{state}->{config} || {}
            );
        }
    }->();
    my $store = sub {
        my $postfix = $conf->{store}->{module} or die "missing session store module name";
        my $klass = "HTTP::Session::Store::${postfix}";
        $klass->require or die $@;
        $klass->new( $conf->{store}->{config} );
    }->();

    my $auth = join(',', $req->headers->authorization_basic);
    if ($state->isa('Moxy::Session::State::BasicAuth') && !$auth) {
        $self->log(debug => 'basicauth');
        return res(
            401,
            [
                WWW_Authenticate => qq{Basic realm="Moxy needs basic auth.Only for identification.Password is dummy."},
            ],
            'authentication required',
        );
    } else {
        $self->log(debug => "session: state: $state, store: $store");
        my $session = HTTP::Session->new(
            state   => $state,
            store   => $store,
            request => $req,
        );
        $self->log(debug => "session: $session");
        my $res = $self->_make_response(
            req     => $req,
            session => $session,
        );
        $session->response_filter($res);
        $session->finalize;

        return $res;
    }
}

sub _make_response {
    my $self = shift;
    my %args = validate(
        @_ => +{
            req     => { isa => 'Moxy::Request', },
            session => { type => OBJECT },
        }
    );
    my $req = $args{req};

    my $base = $req->uri->clone;
    $base->path('');
    $base->query_form({});

    (my $url = $req->uri->path_query) =~ s!^/!!;
    $url = uf_uristr(uri_unescape($url));

    if ($url) {
        # do proxy
        my $res = $self->_do_request(
            url     => $url,
            request => $req->as_http_request,
            session => $args{session},
        );
        $self->log(debug => '-- response status: ' . $res->code);

        if ($res->code == 302) {
            # rewrite redirect
            my $location = URI->new($res->header('Location'));
            $self->log(debug => "redirect to $location");
            my $uri = URI->new($url);
            if (not defined $location->scheme) {
                # path only redirect is invalid!
                #   e.g.   Location: /foo/
                $self->log(error => "----------------------------");
                $self->log(error => "INVALID REDIRECT!! $location");
                $self->log(error => "----------------------------");
                $location = URI->new( $location->as_string, $uri->scheme );
                $location->scheme($uri->scheme);
                $location->host($uri->host);
                $location->port($uri->port);
                $self->log(error => "FIXED TO: $location");
                $self->log(error => "----------------------------");
            } else {
                if ($uri->port != 80 && $location->port != $uri->port) {
                    $location->port($uri->port);
                }
            }
            my $redirect = $base . '/' . uri_escape($location);
            $self->log(debug => "redirect to $redirect");
            return res(
                302, [
                    Location => $redirect,
                ],
            );
        } else {
            my $content_type = $res->header('Content-Type');
            $self->log(debug => "Content-Type: $content_type");
            if ($content_type =~ /html/i) {
                $res->content( encode($res->charset, rewrite_html($base, decode($res->charset, $res->content), $url), Encode::FB_HTMLCREF) );
            } elsif ($content_type =~ m{text/css}) {
                $res->content( encode($res->charset, rewrite_css($base, decode($res->charset, $res->content), $url), Encode::FB_HTMLCREF) );
            }

            return $res->to_plack_response();
        }
    } else {
        # please input url.
        my $response = HTTP::Response->new(
            200 => 'ok', HTTP::Headers->new(
                'content-type' => 'text/html;charset=utf-8',
            ), q{<?xml version="1.0" encoding="utf-8"?>
                <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
                <html lang="ja" xml:lang="ja" xmlns="http://www.w3.org/1999/xhtml">
                <head>
                    <title>moxy start page</title>
                </head>
                <body>
                <div>
                <h1>moxy start page</h1>
                <p>please input url to location bar</p>
                </div>
                </body>
            </html>},
        );
        $response->request($req->as_http_request);
        $self->_post_process(
            response         => $response,
            mobile_attribute => HTTP::MobileAttribute->new('KDDI-KC26 UP.Browser/6.2.0.7.3.129 (GUI) MMP/2.0'),
            session          => $args{session},
        );
        $response->content( encode($response->charset, rewrite_html($base, decode($response->charset, $response->content), ''), Encode::FB_HTMLCREF) );
        return $response->to_plack_response();
    }
}

sub _do_request {
    my $self = shift;
    my %args = validate(
        @_ => +{
            url      => qr{^https?://},
            request  => { isa  => 'HTTP::Request' },
            session  => { type => OBJECT },
        }
    );

    # make request
    my $req = $args{request}->clone;
    $req->uri($args{url});
    $req->header('Host' => do {
            my $u = URI->new($args{url});
            my $header = $u->host;
            $header .= ':' . $u->port if $u->port != 80;
            $header;
        }
    );

    $self->run_hook(
        'request_filter_process_agent',
        {   request => $req, # HTTP::Request object
            session => $args{session},
        }
    );

    my $mobile_attribute = HTTP::MobileAttribute->new($req->headers);
    my $carrier = $mobile_attribute->carrier;
    my $cookie_jar = $args{session}->get('cookies') || HTTP::Cookies->new(); # load cookies
    if ($mobile_attribute->is_docomo) {
        undef $cookie_jar; # docomo phone doesn't support cookies
    }

    for my $hook ('url_handle', "url_handle_$carrier") {
        my $response = $self->run_hook_and_get_response(
            $hook,
            +{
                request          => $req,              # HTTP::Request object
                mobile_attribute => $mobile_attribute,
                session          => $args{session},
            }
        );
        if ($response) {
            return $response; # finished
        }
    }

    # do request
    my $ua = LWP::UserAgent->new(
        timeout           => $self->conf->{global}->{timeout} || 10,
        max_redirects     => 0,
        protocols_allowed => [qw/http https/],
        parse_head        => 0,
        cookie_jar        => $cookie_jar,
    );
    $ua->add_handler( request_prepare => sub {
        my ($req, $ua, $h) = @_;

        for my $hook ('request_filter', "request_filter_$carrier") {
            my $response = $self->run_hook_and_get_response(
                $hook,
                +{
                    request          => $req,              # HTTP::Request object
                    mobile_attribute => $mobile_attribute,
                    session          => $args{session},
                }
            );
            if ($response) {
                return $response; # finished
            }
        }
        $req->remove_header('Accept-Encoding'); # I HATE gziped CONTENT
        $req->remove_header('Cookie');          # remove Cookie from the client

        $req;
    });
    $ua->add_handler( response_done => sub {
        my ($response, $ua, $h) = @_;
        my $location = $response->header('Location');
        if ($location) {
            my $content = $response->content || '';
            $self->log(info => "redirect to '$location', $content");
        }
        $response;
    });

    $self->log(debug => "request to @{[ $req->uri ]}");
    my $t1 = Time::HiRes::gettimeofday();
    my $response = $ua->request($req);
    my $t2 = Time::HiRes::gettimeofday();
    $self->response_time( $t2-$t1 );
    $self->log(debug => "and, request was @{[ $response->request->uri ]}");

    $args{session}->set('cookies' => $cookie_jar); # save cookies

    $self->_post_process(
        response         => $response,
        mobile_attribute => $mobile_attribute,
        session          => $args{session},
    );
    $self->response_time( -1 ); # clear response time

    $response;
}

sub _post_process {
    my $self = shift;
    my %args = validate(
        @_ => {
            response         => 1,
            mobile_attribute => 1,
            session          => 1,
        },
    );

    my $carrier = $args{mobile_attribute}->carrier;
    for my $hook (
        'status_handler',  'security_filter',
        'response_filter', "response_filter_$carrier",
        'render_location_bar'
      )
    {
        $self->run_hook( $hook, \%args );
    }
}

1;
__END__

=for stopwords nyarla-net

=head1 NAME

Moxy - Mobile web development proxy

=head1 DESCRIPTION

Moxy is a mobile web development proxy.

=head1 AUTHOR

    Kan Fushihara
    Tokuhiro Matsuno

=head1 THANKS TO

Kazuhiro Osawa
nyarla-net

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=head1 SEE ALSO

L<http://coderepos.org/share/wiki/ssb>