Group
Extension

Crop-Config/lib/Crop/Server.pm

package Crop::Server;
use base qw/ Crop::Object Exporter /;

=begin nd
Class: Crop::Server
	Protocol-independent a server logic.
	
	Server process client request in the infinite loop.
	
	Create the Server exemplar, register handlers, define workqueue in the router, then listen.

	Router example:

	(start code)
	...
	$Server->router(sub {
	my $S = shift;
	my $I = $S->I;
	return ['ADD'] if exists $I->{name};
	'LIST';
	});
	(end code)
=cut

use v5.14;
use warnings;
no warnings 'experimental';

use Encode qw/ decode /;
use XML::LibXSLT;
use XML::LibXML;
use XML::Simple;
use JSON;
use CGI::Cookie;

use FindBin qw/ $RealBin /;

use Crop::Debug;
use Crop::Error qw/ all_right warn /;
use Crop::Server::Constants;
use Crop::Server::Handler;
use Crop::HTTP::Session;
use Crop::DateTime;
use Crop::Client;
use Crop::Rights;

=begin nd
Constant: HOMEPAGE
	Homepage of the web-site is always '/'
=cut
use constant {
	HOMEPAGE => '/',
};

=begin nd
Variable: our @EXPORT
	Export by default:
	
	OK - handler return status
=cut
our @EXPORT = qw/ OK PAGELESS /;

=begin nd
Variable: our %Attributes
	Attributes:

	auth      - if true an autentication is required; since true by default, you should redefine the <authenticate ( )> method
	client    - client ID
	content   - body of the response
	cwd       - current working directory
	data      - persistent data across all the handlers of an client request
	json      - JSON exemplar
	handler   - hash of registered handlers
	headers   - HTTP headers for response
	iflow     - Input flow of data (see <I ( )>)
	oflow     - Output flow of data (see <O ( )>)
	            special item named {ERROR} contains hashref of all the errors ocquired (see <Crop::Error>)
	output_t  - type of output ('XML', 'XSLT', 'JSON', and so on)
	page      - filename of output template in cwd
	redirect  - URL to redirect
	request   - client request object, <Crop::HTTP::Request> exemplar; will be Saved manually, so stable=>1
	rights    - rights granted to the client, exemplar of <Crop::Rights>
	router    - method establishes a chain of handlers calls; the reuslt of method is array of handler names
	sendfile  - exemplar of <Crop::File::Send> allowes to download file <Crop::File> from inner url (for Nginx)
	session   - exemplar of <Crop::HTTP::Session>
	tik       - start time of request as <Crop::DateTime> object; server sets a corresponding tok at done time
	user      - user object
	workqueue - array of handlers names establishes executing order
	xslt      - hash of parsed XSLT templates
	
	The 'stable' attribute means 'do not cleanup after request has done'.
=cut
our %Attributes = (
	auth      => {default => 1, stable => 1},
	client    => {mode => 'read'},
	content   => undef,
	cwd       => {stable => 1},
	data      => undef,
	json      => {stable => 1},
	handler   => {stable => 1},
	headers   => undef,
	iflow     => undef,
	oflow     => {mode => 'read'},
	output_t  => {default => 'XSLT', stable => 1},
	page      => undef,
	redirect  => undef,
	request   => {mode => 'read', stable => 1},
	rights    => {mode => 'read'},
	router    => {mode => 'write', stable => 1},  # the same all the time, altough result changes
	sendfile  => {mode => 'read/write'},
	session   => {mode => 'read', stable => 1},   # temporary hack!!! needed by $S->session->restore_param in a script
	tik       => {mode => 'read'},
	user      => undef,
	workqueue => undef,
	xslt      => {stable => 1},
);

=begin nd
Variable: our $Server
	Singlton.
	
	<Crop> 'uses' this class, so Server object must be available from outer classes by accessing directly
	without use of methods of this class.
	
	Getter is <instance ( )>.
=cut
our $Server;

=begin nd
Variable: my $Interrupted
	Signal to interrupt the Server has received.

Variable: my $AtWork
	Server at work should not be killed.
=cut
my ($Interrupted, $AtWork);

# Try the Server to be terminated correctly as possible
$SIG{TERM} = $SIG{INT} = \&interrupt;

=begin nd
Constructor: new ( )
=cut
sub new {
	my $class = shift;
	
	$Server = $class->SUPER::new(
		cwd    => $RealBin,
		@_,
	);
	
	$Server->{json} = JSON->new->allow_nonref->allow_blessed->convert_blessed if $Server->{output_t} eq 'JSON';
# 	$Server->{json}->pretty->canonical if $Server->{json} and Crop::C->{environmentType} ne 'production';

	$Server;
}

=begin nd
Method: add_handler ($name, $handler)
	Add new request handler for the Server main loop.
	
	Order of calls does not matter.
	
	Parses XSLT template for future use.

	(start code)
	$Server->add_handler(ADD => {
		page => 'mypage',  # mypage.xsl template in use; the 'index.xsl' by default; use the 'PAGELESS' constant unless output exists
		input => {
			allow => [qw/ id name description /],
		},
		call => sub {
			my $S = shift;
			my $I = $S->I;
			my $O = $S->O;

			$O->{result} = 25;

			OK;
		},
	});
	(finish code)
	
Parameters:
	$name    - handler name
	$handler - subref
=cut
sub add_handler {
	my ($self, $name, $handler) = @_;

	return warn "SERVER|CRIT: Redefining of handler $name is prohibited" if exists $self->{handler}{$name};

	my $Handler = Crop::Server::Handler->new($handler, name => $name) or return warn "HANDLER|CRIT: Can't initiate handler $name";

	$self->{handler}{$name} = $Handler;
	my $page = $Handler->page || DEFAULT_TPL;

	unless ($page eq PAGELESS) {
		if ($self->{output_t} eq 'XSLT' or $self->{output_t} eq 'XML') {
			eval {
				$self->{xslt}{$page} = XML::LibXSLT->new->parse_stylesheet_file("$self->{cwd}/$page" . XSLT_SUFFIX);
			};
			warn "SERVER|CRIT: $@$!" if $@;
		}
	}
}

=begin nd
Method: _authenticate ( )
	Perform client autentication.
	
	By default returns false, meanning 'the autentication failed'. The 'AUTH' error will arised.
	
	Subclass must redefine this method if any logic is required.

Returns:
	true  - autentication is successful
	flase - if autentication failed
=cut
sub _authenticate { undef }

=begin nd
Method: _cleanup ( )
	Finalyze the current client request.
	
	Cleans all attributes for security reason, so next request will not see stale values.

	Kill the server if it has been marked as 'interrupted'.
=cut
sub _cleanup {
	my $self = shift;

	# reset request-specific data to defaults
	$self->Erase;

	$self->{session}->Save;
	
	$self->{request}->tok(Crop::DateTime->new->timestamp);
	$self->{request}->Save;

	$Interrupted ? die("SERVER|NOTICE: Server stopped") : undef $AtWork;
}

=begin nd
Method: D ( )
	Getter for the 'data' attribute.

Returns:
	$self->{data}
=cut
sub D { shift->{data} }

=begin nd
Method: fail ($err)
	Stop a request if the current handler fails.
	
	Interrupts the working chain and arise the $error.

	Redirect is produced to referer page unless defined html template.

Parameters:
	$err - error message
=cut
sub fail {
	my ($self, $err) = @_;

	warn $err;

# 	debug 'CROPSERVER_FAIL_PAGE=', $self->{handler}->page;
# 	if ($self->{handler}->page eq 'PAGELESS') {
# 		debug 'CROPSERVER_FAIL_PAGELESS';
# 		$self->redirect('/');
# 		$self->redirect($self->{request}->referer);
# 	}

	FAIL;
}

=begin nd
Method: _flush ( )
	Flush response to the client.
	
	Pure virtual method; should be redefined by subclass.
=cut
sub _flush {
	warn 'SERVER|CRIT: Crop::Server->_flush() must be redefined by subclass';
}

=begin nd
Method: I ( )
	The getter of input flow (see the 'iflow' attribute).

Returns:
	hashref of input data
=cut
sub I { shift->{iflow} }

=begin nd
Method: _init ( )
	Initiate request handler loop.
	
	Each iteration process one certain HTTP request:
	
	- remember a start time
	- setup flag 'at work' to prevent destructive cancelation
	- cleanup error stack
	- init HTTP request object and session object
	- cleanup data flows
	- parse incomming CGI parameters
	- exec autentication procedure
	- define workqueue by runing router
	- setup current output page
	
Returns:
	nothing, but errors will established when process goes wrong
=cut
sub _init {
	my $self = shift;

	# remember start time as soon as posible
	$self->{tik} = Crop::DateTime->new;
	
	# it is not a good idea to kill the Server at work
	$AtWork = 1;

	# init Error module
	Crop::Error->erase;
	
	$self->{rights} = Crop::Rights->new;
	$self->{rights}->add_role('admin');

	# set HTTP session and request
	$self->_init_httprequest;

	my $session;
	if (defined $self->{request}->cookie_in) {
		$session = Crop::HTTP::Session->Get(cookie  => $self->{request}->cookie_in);
		$session  //= Crop::HTTP::Session->Get(cookie2 => $self->{request}->cookie_in);
		
		if ($session) {
			$session->generate_cookie;
			$session->mtime($self->{request}->tik);
		}
	}
	unless ($session) {
		$session = Crop::HTTP::Session->new(
			ctime => $self->{request}->tik,
			mtime => $self->{request}->tik,
		);
		$session->generate_cookie;
		$session->Genkey;
	}
	$self->{session} = $session;
	
	unless ($self->{session}->id_client) {
		my $client = Crop::Client->new->Save;
		
		$self->{session}->id_client($client->id);
	}
	
	$self->{request}->id_session($session->id);
	$self->{request}->cookie_out($session->cookie);

	# parse the input flow before the dispatcher starts their work since it operate on input parameters
	$self->{iflow} = {};
	$self->{oflow} = {};
	
	# init data persistent across all the handlers in request
	$self->{data} = {};

	# parse input params
	$self->_parse_input;
	my $json;
	if (defined $self->{request}->content_t and $self->{request}->content_t eq 'application/json') {
		$json = $self->{iflow}{POSTDATA};
	} elsif (exists $self->{iflow}{json} and keys %{$self->{iflow}} == 1) {
		$json = $self->{iflow}{json};
	}
	$self->_parse_json($json) if $json;

	# Authentication
	if ($self->{auth}) {
		$self->_authenticate or warn 'AUTH: Authentication failed';
	}

	# define handlers order
	$self->{workqueue} = defined $self->{router} ? $self->{router}->($self) : ['DEFAULT'] or warn 'NOHANDLER|CRIT: No any handler match the request';
	$self->{workqueue} = [$self->{workqueue}] unless ref $self->{workqueue};  # make arrayref, so router may return handler name as a plain string

	# page could be redefined by handler
	$self->{page} = DEFAULT_TPL;
}

=begin nd
Function: interrupt ($signal)
	Handler of TERM or INT signals.
	
	Interrupt Server gracefully.

	The Apache web server sends TERM for '$ apachectl graceful' command.

	This function uses global 'my' variables instead of server attributes for cut dependency of Server constructor.
	
Parameters:
	$signal - signal
=cut
sub interrupt {
	my $signal = shift;

	warn "SERVER|NOTICE: The $signal signal received";

	$AtWork ? $Interrupted = 1 : die 'SERVER|NOTICE: Server stoped';
};

=begin nd
Method: _init_httprequest ( )
	Create <Crop::HTTP::Request> object from client data.
	
	Pure virtual method.
=cut
sub _init_httprequest { warn 'Crop::Server::_init_httprequest() must be redefined by a subclass' }

=begin nd
Method: instance ( )
	Get Singlton.

Returns:
	$Server object as an singlton.
=cut
sub instance { $Server }

=begin nd
Method: _is_json_correct ($json)
	Check incomming JSON for correctness.

	Input should consists of hashref where items are scalar either hashref.

	Tree traverse uses a loop+stack.

Returns:
	1     - is correct
	undef - otherwise
=cut
sub _is_json_correct {
	my ($self, $json) = @_;

	# 'left' contains a names of json objects of current node were not processed
	# 'node' is the current item of parsing; if left=undef, node were not processed
	my @stack = {left => undef, node => $json};

	while (my $frame = pop @stack) {
		my $node = $frame->{node};
		next unless ref $node;
		
		if (ref $node eq 'HASH') {
			# remember keys of child nodes at the begining of parsing current node
			$frame->{left} = [keys %$node] unless defined $frame->{left};

			@{$frame->{left}} or next;  # all childs is done
			my $key = pop @{$frame->{left}};

			# frame with remaining nodes goes back to the stack for a later parsing
			push @stack, $frame;

			# no info about this node, so 'left' is undef
			push @stack, {left => undef, node => $node->{$key}};
		} elsif (ref $node eq 'ARRAY') {
# 			debug 'ARRAY_AT_JSON';
		} elsif (ref $node eq 'JSON::PP::Boolean') {
			# $$node = 0 or 1; boolean type
		} else {
			return warn 'INPUT: JSON parsing error';  # json is incorrect
		}
	}

	1;
}

=begin nd
Method: listen ( )
	Main loop serves client requests. Pure virtual.

Returns:
	Infinite loop, do not return.
=cut
sub listen {
	return warn 'SERVER|CRIT: Crop::Server::listen() must be redefined by a subclass';
}

=begin nd
Method: O ( )
	The getter of output flow.

Returns:
	hashref
=cut
sub O { shift->{oflow} }

=begin nd
Method: _output ( )
	Build the output and send it to a client.
=cut
sub _output {
	my $self = shift;

	if (defined $self->{sendfile}) {
		$self->_sendfile;
		return;
	}
	
	Crop::Error::bind $self->{oflow}{ERROR};

	my $cookie = CGI::Cookie->new(
		-name    => 'session',
		-value   => $self->{request}->cookie_out,
# 		-domain  => '.' . $self->Config->{host},
		-expires => '+365d',
	);
	my $cookie_secure = CGI::Cookie->new(
		-name    => 'session',
		-value   => $self->{request}->cookie_out,
		-expires => '+365d',
		-secure  => 1,
		-samsite => 'None',
	);
	my ($content, $content_t);
	given ($self->{output_t}) {
		when ('XSLT') {
			if (defined $self->{redirect}) {
# 			unless (defined $self->{redirect}) {
				$self->_redirect;
			} else {
				$self->{headers} = [
					-type   => 'text/html; charset=utf-8',
					-cookie => [$cookie_secure, $cookie],
				];

				my $xml_source = XMLout($self->{oflow}, SuppressEmpty => 1);
				debug DL_SRV, "xml_source=", $xml_source;
				my $xml_parser = XML::LibXML->new;
				my $xml = $xml_parser->load_xml(string => $xml_source);

				my $page = $self->{page};
				my $xslt = $self->{xslt}{$page};
				my $result = $self->{xslt}{$page}->transform($xml);
				$self->{content} = "<!DOCTYPE html>\n" . $xslt->output_as_bytes($result);
			}
		}
		when ('JSON') {
			$self->{headers} = [
				-type   => 'application/json',
# 				-type   => 'text/html; charset=utf-8',
				-cookie => [$cookie_secure, $cookie],
# 				-cookie => $cookie,
			];

			$self->{content} = $self->{json}->utf8->encode($self->{oflow});
		}
		default: warn 'SERVER|ALERT: Unknown content type';
	}
	
	$self->_flush;
}

=begin nd
Method: _parse_input ( )
	Build an input flow ($self->{iflow}) from incomming parameters.
	
	Parameters can contain a dot separator
	>user.name=john&user.email=gmail&
	so the hash will constructed
	>{user => {name=>john, email=>gmail}}
	in the $self->{iflow}
=cut
sub _parse_input {
	my $self = shift;

	my $param = $self->{request}->param;
	for my $name (keys %$param) {
		# separate name to nested hashes
		my @keys = split '\.', $name;

		# only the last name is a lvalue that suitable for assign to parameter
		my $last = pop @keys;

		# step down for all name's parts
		my $target = $self->{iflow};
		for (@keys) {
			# create a new hash for current name
			exists $target->{$_} and ref $target->{$_} eq 'HASH' or $target->{$_} = {};

			# do next step down
			$target = $target->{$_};
		}
		$target->{$last} = decode 'utf8', $param->{$name};
	}
}

=begin nd
Method: _parse_json ($src)
	Decode incomming JSON.
	
	Save result to iflow.
	
Parameters:
	$src - raw JSON from either GET, POST, packed to special param, or Content-Type marked
	
Returns:
	iflow - if ok
	undef - otherwise
=cut
sub _parse_json {
	my ($self, $src) = @_;
	
	my $json = $self->{json}->decode($src);
	$self->_is_json_correct($json) or return warn 'INPUT: Incorrect JSON';

	$self->{iflow} = $json;
}

=begin nd
Method: redirect ($url)
	Make a client redirect to $url in a script.

	> return $Server->redirect('/');

	Sets redirect attribute to the $url, and returns special constant that means 'make redirect' in the main loop of Server.

Parameters:
	$url - addres to redirect

Returns:
	<Crop::Server::Constants::REDIRECT>
=cut
sub redirect {
	my ($self, $url) = @_;

	$self->{redirect} = $url // HOMEPAGE;

	REDIRECT;
}

=begin nd
Method: _redirect ( )
	Send redirect to a client.

	Pure virtual method must be redefined by subclass.
=cut
sub _redirect {
	warn 'SERVER|ALERT: ' . __PACKAGE__ . '::_redirect() method must be redefined by a subclass';
}

=begin nd
Method: _sendfile ( )
	Send X-Accel-Redirect to a client to allow download.
	
	Pure virtual method must be redefined by a subclass.
=cut
sub _sendfile {
	warn 'SERVER|ALERT: ' . __PACKAGE__ . '::_sendfile() method must be redefined by subclass';
}

=begin nd
Method: _work ( )
	Handle current request.
	
	Altough Gateway Interfaces are different, general logic is the same for every client request.
=cut
sub _work {
	my $self = shift;
	
	$self->_init;
	if (all_right) {
		HANDLER:
		while (my $name = shift @{$self->{workqueue}}) {
# 			debug 'HANDLER_NAME=', $name;
			
			unless (exists $self->{handler}{$name}) {
				warn "SERVER|CRIT: No such Handler: $name";
				last HANDLER;
			}
			my $handler = $self->{handler}{$name};

			# check client input for allowed parameters
			unless ($handler->checkin($self->{iflow})) {
				warn 'INPUT|ERR: Input flow does not satisfy to rules of handler ', $handler->name;
				last HANDLER;
			}
			
			my $rc = $handler->call->($self);
			given ($rc) {
				when (OK) {
# 					debug DL_SRV, "RC of handler $name is ", OK;
					$self->{page} = $handler->page || DEFAULT_TPL;
				}
				when (FAIL) {
# 					debug DL_SRV, "RC of handler $name is ", FAIL;
					if ($handler->page eq PAGELESS) {
						$self->{redirect} = $self->{request}->referer;  # TODO: check the original domain
# 						$self->_redirect;

						$self->{session}->pass_thru;
					} else {
						$self->{page} = $handler->page || DEFAULT_TPL;
					}
					last HANDLER;
				}
				when (REDIRECT) {
# 					debug DL_SRV, "RC of handler $name is ", REDIRECT;
# 					$self->_redirect;
					last HANDLER;
				}
				when (WORKFLOW) {
# 					debug DL_SRV, "RC of handler $name is ", WORKFLOW;
				}
				default {
					warn "SERVER|CRIT: Unknown return code: '$rc' in Handler $name";
					last HANDLER;
				}
			}
		}
	}
	warn "SERVER|CRIT: Error at main loop" unless all_right;

	$self->_output;
	$self->_cleanup;
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.