#!/usr/bin/perl

package JMX::Jmx4Perl::Agent::Jolokia::WebXmlHandler;

=head1 NAME

JMX::Jmx4Perl::Agent::Jolokia::WebXmlHandler - Handler for web.xml 
transformation 

=head1 DESCRIPTION

This module is repsonsible for various manipulations on a F<web.xml> descriptor
as found in JEE WAR archives. It uses L<XML::LibXML> for the dirty work, and
L<XML::Tidy> to clean up after the manipulation. The later module is optional,
but recommended. 

=head1 METHODS

=over 4 

=cut

use Data::Dumper;
use vars qw($HAS_LIBXML $HAS_XML_TIDY);
use strict;

# Trigger for <login-config>
my $REALM = "Jolokia";

# Class used as proxy dispatcher
my $JSR_160_PROXY_CLASS = "org.jolokia.jsr160.Jsr160RequestDispatcher";

BEGIN {
    $HAS_LIBXML = eval "require XML::LibXML; use XML::LibXML::XPathContext; 1";
    $HAS_XML_TIDY = eval "require XML::Tidy; 1";
}

=item $handler = JMX::Jmx4Perl::Agent::Jolokia::WebXmlHandler->new(%args)

Creates a new handler. The following arguments can be used:

  "logger"     Logger to use

=cut

sub new { 
    my $class = shift;
    my %args = @_;
    my $self = {logger => $args{logger}};
    bless $self,(ref($class) || $class);

    $self->_fatal("No XML::LibXML found. Please install it to allow changes and queries on web.xml") unless $HAS_LIBXML;

    return $self;
}

=item $handler->add_security($webxml,{ role => $role })

Add a security constraint to the given web.xml. This triggers on the realm
"Jolokia" on the loging-config and the URL-Pattern "/*" for the security
mapping. Any previous sections are removed and replaced.

C<$role> is the role to insert.

This method returns the updated web.xml as a string.

=cut

sub add_security {
    my $self = shift;
    my $webxml = shift;
    my $args = shift;

    my $doc = XML::LibXML->load_xml(string => $webxml);
    
    my $parent = $doc->getDocumentElement;
    $self->_remove_security_elements($doc);

    $self->_create_login_config($doc,$parent);
    $self->_create_security_constraint($doc,$parent,$args->{role});
    $self->_create_security_role($doc,$parent,$args->{role});
    $self->_info("Added security mapping for role ","[em]",$args->{role},"[/em]");    
    return $self->_cleanup_doc($doc);
}

=item $handler->remove_security($webxml)

Remove login-config with Realm "Jolokia" and security constraint to 
"/*" along with the associated role definit. Return the updated web.xml 
as string.

=cut

sub remove_security {
    my $self = shift;
    my $webxml = shift;

    my $doc = XML::LibXML->load_xml(string => $webxml);

    $self->_remove_security_elements($doc);
    $self->_info("Removed security mapping");
    
    return $self->_cleanup_doc($doc);
}

=item $handler->add_jsr160_proxy($webxml)

Adds a JSR-160 proxy declaration which is contained as init-param of the
servlet definition ("dispatcherClasses"). If the init-param is missing, a new
is created otherwise an existing is updated. Does nothing, if the init-param
"dispatcherClasses" already contains the JSR 160 dispacher.

Returns the updated web.xml as string.

=cut

sub add_jsr160_proxy {
    my $self = shift;
    my $webxml = shift;

    my $doc = XML::LibXML->load_xml(string => $webxml);
    my @init_params = $self->_init_params($doc,"dispatcherClasses");
    if (!@init_params) {
        $self->_add_jsr160_proxy($doc);
        $self->_info("Added JSR-160 proxy");
    } elsif (@init_params == 1) {
        my $param = $init_params[0];
        my ($value,$classes) = $self->_extract_dispatcher_classes($init_params[0]);
        unless (grep { $_ eq $JSR_160_PROXY_CLASS } @$classes) {
            $self->_update_text($value,join(",",@$classes,$JSR_160_PROXY_CLASS));
            $self->_info("Added JSR-160 proxy");
        } else {
            $self->_info("JSR-160 proxy already active");
            return undef;
        }
    } else {
        # Error
        $self->_fatal("More than one init-param 'dispatcherClasses' found");
    }

    return $self->_cleanup_doc($doc);
}

=item $handler->remove_jsr160_proxy($webxml)

Removes a JSR-160 proxy declaration which is contained as init-param of the
servlet definition ("dispatcherClasses"). Does nothing, if the init-param
"dispatcherClasses" already doese not contain the JSR 160 dispacher.

Returns the updated web.xml as string.

=cut

sub remove_jsr160_proxy {
    my $self = shift;
    my $webxml = shift;
    
    my $doc = XML::LibXML->load_xml(string => $webxml);
    my @init_params = $self->_init_params($doc,"dispatcherClasses");
    if (!@init_params) {
        $self->info("No JSR-160 proxy active");
        return undef;
    } elsif (@init_params == 1) {
        my ($value,$classes) = $self->_extract_dispatcher_classes($init_params[0]);
        if (grep { $_ eq $JSR_160_PROXY_CLASS } @$classes) {
            $self->_update_text($value,join(",",grep { $_ ne $JSR_160_PROXY_CLASS } @$classes));
            $self->_info("Removed JSR-160 proxy");
        } else {
            $self->_info("No JSR-160 proxy active");
            return undef;
        }
    } else {
        $self->_fatal("More than one init-param 'dispatcherClasses' found");
    }

    return $self->_cleanup_doc($doc);
}

=item $handler->find($webxml,$xquery)

Find a single element with a given XQuery query. Croaks if more than one
element is found. Returns either C<undef> (nothing found) or the matched
node's text content.

=cut

sub find {
    my $self = shift;
    my $webxml = shift;
    my $query = shift;

    my $doc = XML::LibXML->load_xml(string => $webxml);
    
    my @nodes = $self->_find_nodes($doc,$query);
    $self->_fatal("More than one element found matching $query") if @nodes > 1;
    return @nodes == 0 ? undef : $nodes[0]->textContent;
}

=item $handler->has_authentication($webxml)

Checks, whether authentication is switched on.

=cut

sub has_authentication {
    my $self = shift;
    my $webxml = shift;
    
    $self->find
      ($webxml,
       "//j2ee:security-constraint[j2ee:web-resource-collection/j2ee:url-pattern='/*']/j2ee:auth-constraint/j2ee:role-name");
}

=item $handler->has_jsr160_proxy($webxml)

Checks, whether a JSR-160 proxy is configured.

=cut

sub has_jsr160_proxy {
    my $self = shift;
    my $webxml = shift;

    my $doc = XML::LibXML->load_xml(string => $webxml);

    my @init_params = $self->_init_params($doc,"dispatcherClasses");
    if (@init_params > 1) {
        $self->_fatal("More than one dispatcherClasses init-param found");
    } elsif (@init_params == 1) {
        my $param = $init_params[0];
        my ($value,$classes) = $self->_extract_dispatcher_classes($init_params[0]);
        return grep { $_ eq $JSR_160_PROXY_CLASS } @$classes;
    } else {
        return 0;
    }
}

# =============================================================================== 

sub _remove_security_elements {
    my $self = shift;
    my $doc = shift;
    my $role = shift;

    $self->_remove_login_config($doc);
    my $role = $self->_remove_security_constraint($doc);
    $self->_remove_security_role($doc,$role);
}

sub _create_login_config {
    my $self = shift;
    my $doc = shift;
    my $parent = shift;
    my $l = _e($doc,$parent,"login-config");
    _e($doc,$l,"auth-method","BASIC");
    _e($doc,$l,"realm-name",$REALM);
}

sub _create_security_constraint {
    my $self = shift;
    my $doc = shift; 
    my $parent = shift;
    my $role = shift;

    my $s = _e($doc,$parent,"security-constraint");
    my $w = _e($doc,$s,"web-resource-collection");
    _e($doc,$w,"web-resource-name","Jolokia-Agent Access");
    _e($doc,$w,"url-pattern","/*");
    my $a = _e($doc,$s,"auth-constraint");
    _e($doc,$a,"role-name",$role);
}

sub _create_security_role {
    my $self = shift;
    my $doc = shift; 
    my $parent = shift;
    my $role = shift;

    my $s = _e($doc,$parent,"security-role");
    _e($doc,$s,"role-name",$role);
}

sub _remove_security_constraint {
    my $self = shift;
    my $doc = shift;

    my @s = $doc->getElementsByTagName("security-constraint");
    for my $s (@s) {
        my @r = $s->getElementsByTagName("role-name");
        my $role;
        for my $r (@r) {
            $role = $r->textContent;
        }
        my @u = $s->getElementsByTagName("url-pattern");
        for my $u (@u) {
            if ($u->textContent eq "/*") {
                $s->parentNode->removeChild($s);
                return $role;                
            }
        }
    }    
}

sub _remove_login_config {
    my $self = shift;
    my $doc = shift;

    my @l = $doc->getElementsByTagName("realm-name");
    for my $l (@l) {
        if ($l->textContent eq $REALM) {
            my $toRemove = $l->parentNode;
            $toRemove->parentNode->removeChild($toRemove);
            return;
        }
    }
}

sub _remove_security_role {
    my $self = shift;
    my $doc = shift;
    my $role = shift;

    my @s = $doc->getElementsByTagName("security-role");
    for my $s (@s) {
        my @r = $s->getElementsByTagName("role-name");
        for my $r (@r) {
            if ($r->textContent eq $role) {
                $s->parentNode->removeChild($s);
                return;                
            }
        }
    }        
}

sub _init_params {
    my $self = shift;
    my $doc = shift;
    my $param_name = shift;

    return $self->_find_nodes
      ($doc,
       "/j2ee:web-app/j2ee:servlet[j2ee:servlet-name='jolokia-agent']/j2ee:init-param[j2ee:param-name='$param_name']");
}

sub _extract_dispatcher_classes {
    my $self = shift;
    my $param = shift;

    my @values = $self->_find_nodes($param,"j2ee:param-value");
    $self->_fatal("No or more than one param-value found") if (!@values || @values > 1);
    my $value = $values[0];
    my $content = $value->textContent();
    my @classes = split /\s*,\s*/,$content;
    return ($value,\@classes);
}

sub _update_text {
    my $self = shift;
    my $el = shift;
    my $value = shift;

    my $parent = $el->parentNode;
    $parent->removeChild($el);
    $parent->appendTextChild($el->nodeName,$value);
}

sub _add_jsr160_proxy {
    my $self = shift;
    my $doc = shift;
    my @init_params = $self->_find_nodes
      ($doc,
       "/j2ee:web-app/j2ee:servlet[j2ee:servlet-name='jolokia-agent']/j2ee:init-param");
    my $first = $init_params[0] || $self->_fatal("No init-params found");
    my $new_init = $doc->createElement("init-param");
    _e($doc,$new_init,"param-name","dispatcherClasses");
    _e($doc,$new_init,"param-value",$JSR_160_PROXY_CLASS);        
    $first->parentNode->insertBefore($new_init,$first);
}

sub _find_nodes {
    my $self = shift;
    my $doc = shift;
    my $query = shift;

    my $xpc = XML::LibXML::XPathContext->new;
    $xpc->registerNs('j2ee', 'http://java.sun.com/xml/ns/j2ee');
    return $xpc->findnodes($query,$doc);
}

sub _e {
    my $doc = shift;
    my $parent = shift;
    my $e = $doc->createElement(shift);
    my $c = shift;
    if ($c) {
        $e->appendChild($doc->createTextNode($c));
    }
    $parent->appendChild($e);
    return $e;
}

sub _cleanup_doc {
    my $self = shift;
    my $doc = shift;
    if ($HAS_XML_TIDY) {
        my $ret = new XML::Tidy(xml => $doc->toString())->tidy->toString();
        #print $ret;
        return $ret;
    } else {
        return $doc->toString(1);
    }
}

sub _fatal {
    my $self = shift;
    $self->{logger}->error(@_);
    die "\n";
}

sub _info {
    my $self = shift;
    $self->{logger}->info(@_);
}

=back 

=head1 LICENSE

This file is part of jmx4perl.
Jmx4perl is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
The Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
 
jmx4perl 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 jmx4perl.  If not, see <http://www.gnu.org/licenses/>.

A commercial license is available as well. Please contact roland@cpan.org for
further details.

=head1 AUTHOR

roland@cpan.org

=cut

1;
