File Coverage

blib/lib/Lavoco/Web/App.pm
Criterion Covered Total %
statement 4 6 66.6
branch n/a
condition n/a
subroutine 2 2 100.0
pod n/a
total 6 8 75.0


line stmt bran cond sub pod time code
1             package Lavoco::Web::App;
2              
3 1     1   20158 use 5.006;
  1         6  
  1         52  
4              
5 1     1   301 use Moose;
  0            
  0            
6              
7             use Data::Dumper;
8             use DateTime;
9             use Email::Stuffer;
10             use Encode;
11             use File::Slurp;
12             use FindBin qw($Bin);
13             use JSON;
14             use Log::AutoDump;
15             use Plack::Handler::FCGI;
16             use Plack::Request;
17             use Template;
18             use Term::ANSIColor;
19             use Time::HiRes qw(gettimeofday);
20              
21             $Data::Dumper::Sortkeys = 1;
22              
23             =head1 NAME
24              
25             Lavoco::Web::App - Experimental framework with two constraints: FastCGI and Template::Toolkit.
26              
27             =head1 VERSION
28              
29             Version 0.05
30              
31             =cut
32              
33             our $VERSION = '0.05';
34              
35             $VERSION = eval $VERSION;
36              
37             =head1 SYNOPSIS
38              
39             Framework to run small web apps, URL dispatching based on a flexible config file, rendering Template::Toolkit templates, running as a FastCGI application.
40              
41             use Lavoco::Web::App;
42            
43             my $app = Lavoco::Web::App->new;
44            
45             my $action = lc( $ARGV[0] ); # (start|stop|restart)
46            
47             $app->$action;
48              
49             =cut
50              
51             =head1 METHODS
52              
53             =head2 Class Methods
54              
55             =head3 new
56              
57             Creates a new instance of the web-app object.
58              
59             =head2 Attributes
60              
61             =cut
62              
63             has processes => ( is => 'rw', isa => 'Int', default => 5 );
64             has base => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build_base' );
65             has dev => ( is => 'rw', isa => 'Bool', lazy => 1, builder => '_build_dev' );
66             has _pid => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build__pid' );
67             has _socket => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build__socket' );
68             has templates => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build_templates' );
69             has filename => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build_filename' );
70             has config => ( is => 'rw', isa => 'HashRef' );
71              
72             sub _build_base
73             {
74             return $Bin;
75             }
76              
77             sub _build_dev
78             {
79             my $self = shift;
80              
81             return 0 if $self->base =~ m:/live:;
82              
83             return 1;
84             }
85              
86             sub _build__pid
87             {
88             my $self = shift;
89              
90             return $self->base . '/app.pid';
91             }
92              
93             sub _build__socket
94             {
95             my $self = shift;
96              
97             return $self->base . '/app.sock';
98             }
99              
100             sub _build_templates
101             {
102             my $self = shift;
103              
104             return $self->base . '/templates';
105             }
106              
107             sub _build_filename
108             {
109             my $self = shift;
110              
111             return $self->base . '/app.json';
112             }
113              
114             =head3 base
115              
116             The base directory of the application, detected using L<FindBin>.
117              
118             =head3 dev
119              
120             A simple boolean flag to indicate whether you're running a development instance of the web-app.
121              
122             It's on by default, and currently turned off if the base directory contains C</live>. Feel free to set it based on your own logic before calling C<start()>.
123              
124             I typically use working directories such as C</home/user/www.example.com/dev> and C</home/user/www.example.com/live>.
125              
126             This flag is useful to disable things like Google Analytics on the dev site.
127              
128             The application object is available to all templates under the name C<app>.
129              
130             e.g. C<[% IF app.dev %] ... [% END %]>
131              
132             =head3 processes
133              
134             Number of FastCGI process to spawn, 5 by default.
135              
136             $app->processes( 10 );
137              
138             =head3 templates
139              
140             The directory containing the TT templates, by default it's C<$app-E<gt>base . '/templates'>.
141              
142             =head3 filename
143              
144             Filename for the config file, default is C<app.json> and only JSON is currently supported.
145              
146             =head3 config
147              
148             The config as a hash-reference.
149              
150             =head2 Instance Methods
151              
152             =head3 start
153              
154             Starts the FastCGI daemon. Performs basic checks of your environment and dies if there's a problem.
155              
156             =cut
157              
158             sub start
159             {
160             my $self = shift;
161              
162             if ( -e $self->_pid )
163             {
164             print "PID file " . $self->_pid . " already exists, I think you should kill that first, or specify a new pid file with the -p option\n";
165            
166             return $self;
167             }
168              
169             $self->_init;
170              
171             print "Building FastCGI engine...\n";
172            
173             my $server = Plack::Handler::FCGI->new(
174             nproc => $self->processes,
175             listen => [ $self->_socket ],
176             pid => $self->_pid,
177             detach => 1,
178             );
179            
180             $server->run( $self->_handler );
181             }
182              
183             sub _init
184             {
185             my ( $self, %args ) = @_;
186              
187             ###############################
188             # make sure there's a log dir #
189             ###############################
190              
191             printf( "%-50s", "Checking logs directory");
192              
193             my $log_dir = $self->base . '/logs';
194              
195             if ( ! -e $log_dir || ! -d $log_dir )
196             {
197             _print_red( "[ FAIL ]\n" );
198             print $log_dir . " does not exist, or it's not a folder.\nExiting...\n";
199             exit;
200             }
201              
202             _print_green( "[ OK ]\n" );
203              
204             #####################################
205             # make sure there's a templates dir #
206             #####################################
207              
208             printf( "%-50s", "Checking templates directory");
209              
210             if ( ! -e $self->templates || ! -d $self->templates )
211             {
212             _print_red( "[ FAIL ]\n" );
213             print $self->templates . " does not exist, or it's not a folder.\nExiting...\n";
214             exit;
215             }
216              
217             _print_green( "[ OK ]\n" );
218              
219             ###########################
220             # make sure 404.tt exists #
221             ###########################
222              
223             printf( "%-50s", "Checking 404 template");
224              
225             my $template_404_file = $self->templates . '/404.tt';
226              
227             if ( ! -e $template_404_file )
228             {
229             _print_red( "[ FAIL ]\n" );
230             print $template_404_file . " does not exist.\nExiting...\n";
231             exit;
232             }
233              
234             _print_green( "[ OK ]\n" );
235              
236             ########################
237             # load the config file #
238             ########################
239              
240             printf( "%-50s", "Checking config");
241              
242             if ( ! -e $self->filename )
243             {
244             _print_red( "[ FAIL ]\n" );
245             print $self->filename . " does not exist.\nExiting...\n";
246             exit;
247             }
248              
249             my $string = read_file( $self->filename, { binmode => ':utf8' } );
250              
251             my $config = undef;
252              
253             eval {
254             my $json = JSON->new;
255              
256             $json->relaxed( 1 );
257              
258             $config = $json->decode( $string );
259             };
260              
261             if ( $@ )
262             {
263             _print_red( "[ FAIL ]\n" );
264             print "Config file error...\n" . $@ . "Exiting...\n";
265             exit;
266             }
267              
268             ###################################
269             # basic checks on the config file #
270             ###################################
271              
272             if ( ! $config->{ pages } )
273             {
274             _print_red( "[ FAIL ]\n" );
275             print "'pages' attribute missing at top level.\nExiting...\n";
276             exit;
277             }
278              
279             if ( ref $config->{ pages } ne 'ARRAY' )
280             {
281             _print_red( "[ FAIL ]\n" );
282             print "'pages' attribute is not a list.\nExiting...\n";
283             exit;
284             }
285              
286             if ( scalar @{ $config->{ pages } } == 0 )
287             {
288             _print_organge( "[ISSUE]\n" );
289             print "No 'pages' defined in config, this will result in a 404 for all requests.\n";
290             }
291              
292             my %paths = ();
293              
294             foreach my $each_page ( @{ $config->{ pages } } )
295             {
296             if ( ! $each_page->{ path } )
297             {
298             _print_red( "[ FAIL ]\n" );
299             print "'path' attribute missing for page..." . ( Dumper $each_page );
300             exit;
301             }
302              
303             if ( ! $each_page->{ template } )
304             {
305             _print_red( "[ FAIL ]\n" );
306             print "'template' attribute missing for page..." . ( Dumper $each_page );
307             exit;
308             }
309              
310             if ( exists $paths{ $each_page->{ path } } )
311             {
312             _print_red( "[ FAIL ]\n" );
313             print "Path '" . $each_page->{ path } . "' found more than once.\nExiting...\n";
314             exit;
315             }
316              
317             $paths{ $each_page->{ path } } = 1;
318             }
319              
320             _print_green( "[ OK ]\n" );
321              
322             return $self;
323             }
324              
325             sub _print_green
326             {
327             my $string = shift;
328             print color 'bold green';
329             print $string;
330             print color 'reset';
331             }
332              
333             sub _print_orange
334             {
335             my $string = shift;
336             print color 'bold orange';
337             print $string;
338             print color 'reset';
339             }
340              
341             sub _print_red
342             {
343             my $string = shift;
344             print color 'bold red';
345             print $string;
346             print color 'reset';
347             }
348              
349             =head3 stop
350              
351             Stops the FastCGI daemon.
352              
353             =cut
354              
355             sub stop
356             {
357             my $self = shift;
358              
359             if ( ! -e $self->_pid )
360             {
361             return $self;
362             }
363            
364             open( my $fh, "<", $self->_pid ) or die "Cannot open pidfile: $!";
365              
366             my @pids = <$fh>;
367              
368             close $fh;
369              
370             chomp( $pids[0] );
371              
372             print "Killing pid $pids[0] ...\n";
373              
374             kill 15, $pids[0];
375              
376             return $self;
377             }
378              
379             =head3 restart
380              
381             Restarts the FastCGI daemon, with a 1 second delay between stopping and starting.
382              
383             =cut
384              
385             sub restart
386             {
387             my $self = shift;
388            
389             $self->stop;
390              
391             sleep 1;
392              
393             $self->start;
394              
395             return $self;
396             }
397              
398             =head1 CONFIGURATION
399              
400             The app should be a simple Perl script in a folder with the following structure:
401              
402             app.pl # see the synopsis
403             app.json # see below
404             app.pid # generated, to control the process
405             app.sock # generated, to accept incoming FastCGI connections
406             logs/
407             templates/
408             404.tt
409              
410             The config file is read for each and every request, this makes adding new pages easy, without the need to restart the application.
411              
412             The config file should be placed in the C<base> directory of your application.
413              
414             See the C<examples> directory for a sample JSON config file, something like the following...
415              
416             {
417             "pages" : [
418             {
419             "path" : "/",
420             "template":"index.tt",
421             ...
422             },
423             ...
424             ]
425             ...
426             "send_alerts_from":"The Example App <no-reply@example.com>",
427             "send_404_alerts_to":"you@example.com",
428             ...
429             }
430              
431             The entire config hash is available in all templates via C<[% app.config %]>, there are only a couple of mandatory/reserved attributes.
432              
433             The mandatory field in the config is C<pages>, an array of pages.
434              
435             Each C<page> should contain a C<path> (for URL matching) and C<template> to render.
436              
437             All other fields are completely up to you, to fit your requirements.
438              
439             When a request is made, a lookup is performed for a page by matching the C<path>, which then results in rendering the associated C<template>.
440              
441             If no page is found, the template C<404.tt> will be rendered, make sure you have this file ready in the templates directory.
442              
443             The C<page> object is available in the rendered template, eg, C<[% page.path %]>
444              
445             It is often useful to have sub-pages and categories, etc. Simply create a C<pages> attribute in a C<page> object as another array of C<page> objects.
446              
447             If a sub-page is matched and selected for a request, an extra key for C<parents> is included in the C<page> object as a list of the parent pages, this is useful for building breadcrumb links.
448              
449             =cut
450              
451             # returns a code-ref for the FCGI handler/server.
452              
453             sub _handler
454             {
455             my $self = shift;
456              
457             return sub {
458              
459             ##############
460             # initialise #
461             ##############
462              
463             my $req = Plack::Request->new( shift );
464              
465             my %stash = (
466             app => $self,
467             req => $req,
468             now => DateTime->now,
469             started => join( '.', gettimeofday ),
470             );
471              
472             my $log = Log::AutoDump->new( base_dir => $stash{ app }->base . '/logs', filename => 'app.log' );
473              
474             $log->debug("Started");
475              
476             my $path = $req->uri->path;
477              
478             $log->debug( "Requested path: " . $path );
479              
480             $stash{ app }->_reload_config( log => $log );
481              
482             ###############
483             # sitemap xml #
484             ###############
485              
486             if ( $path eq '/sitemap.xml' )
487             {
488             return $stash{ app }->_sitemap( log => $log, req => $req, stash => \%stash );
489             }
490              
491             ##########################################################################
492             # find a matching 'page' from the config that matches the requested path #
493             ##########################################################################
494              
495             # need to do proper recursion here
496              
497             foreach my $each_page ( @{ $stash{ app }->{ config }->{ pages } } )
498             {
499             if ( $path eq $each_page->{ path } )
500             {
501             $stash{ page } = $each_page;
502              
503             last;
504             }
505              
506             if ( ref $each_page->{ pages } eq 'ARRAY' )
507             {
508             foreach my $each_sub_page ( @{ $each_page->{ pages } } )
509             {
510             if ( $path eq $each_sub_page->{ path } )
511             {
512             $stash{ page } = $each_sub_page;
513              
514             $stash{ page }->{ parents } = [];
515            
516             push @{ $stash{ page }->{ parents } }, $each_page;
517            
518             last;
519             }
520             }
521             }
522             }
523              
524             $log->debug( "Matching page found in config...", $stash{ page } ) if exists $stash{ page };
525              
526             #######
527             # 404 #
528             #######
529            
530             if ( ! exists $stash{ page } )
531             {
532             return $stash{ app }->_404( log => $log, req => $req, stash => \%stash );
533             }
534              
535             ##############################
536             # responding with a template #
537             ##############################
538              
539             my $res = $req->new_response;
540              
541             $res->status( 200 );
542              
543             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash{ app }->templates );
544              
545             $log->debug("Processing template: " . $stash{ app }->templates . "/" . $stash{ page }->{ template } );
546              
547             my $body = '';
548              
549             $tt->process( $stash{ page }->{ template }, \%stash, \$body ) or $log->debug( $tt->error );
550              
551             $res->content_type('text/html; charset=utf-8');
552              
553             $res->body( encode( "UTF-8", $body ) );
554              
555             #########
556             # stats #
557             #########
558              
559             $stash{ took } = join( '.', gettimeofday ) - $stash{ started };
560            
561             $log->debug( "The stash contains...", \%stash );
562            
563             $log->debug( "Took " . sprintf("%.5f", $stash{ took } ) . " seconds");
564              
565             #######################################
566             # cleanup (circular references, etc.) #
567             #######################################
568              
569             # need to do deep pages too!
570              
571             delete $stash{ page }->{ parents } if exists $stash{ page };
572              
573             return $res->finalize;
574             }
575             }
576              
577             sub _sitemap
578             {
579             my ( $self, %args ) = @_;
580              
581             my $log = $args{ log };
582             my $req = $args{ req };
583             my $stash = $args{ stash };
584              
585             my $base = ($req->env->{'psgi.url_scheme'} || "http") .
586             "://" . ($req->env->{HTTP_HOST} || (($req->env->{SERVER_NAME} || "") . ":" . ($req->env->{SERVER_PORT} || 80)));
587              
588             my $sitemap = '<?xml version="1.0" encoding="UTF-8"?>';
589              
590             $sitemap .= "\n";
591              
592             $sitemap .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">';
593              
594             $sitemap .= "\n";
595              
596             # need to do proper recursion here
597            
598             foreach my $each_page ( @{ $stash->{ config }->{ pages } } )
599             {
600             $sitemap .= "<url><loc>" . $base . $each_page->{ path } . "</loc></url>\n";
601              
602             if ( ref $each_page->{ pages } eq 'ARRAY' )
603             {
604             foreach my $each_sub_page ( @{ $each_page->{ pages } } )
605             {
606             $sitemap .= "<url><loc>" . $base . $each_sub_page->{ path } . "</loc></url>\n";
607             }
608             }
609             }
610            
611             $sitemap .= "</urlset>\n";
612              
613             my $res = $req->new_response;
614              
615             $res->status(200);
616              
617             $res->content_type('application/xml; charset=utf-8');
618            
619             $res->body( encode( "UTF-8", $sitemap ) );
620              
621             return $res->finalize;
622             }
623              
624             sub _404
625             {
626             my ( $self, %args ) = @_;
627              
628             my $log = $args{ log };
629             my $req = $args{ req };
630             my $stash = $args{ stash };
631              
632             $stash->{ page } = { template => '404.tt' };
633              
634             if ( $stash->{ config }->{ send_alerts_from } && $stash->{ config }->{ send_404_alerts_to } )
635             {
636             $stash->{ app }->_send_email(
637             from => $stash->{ config }->{ send_alerts_from },
638             to => $stash->{ config }->{ send_404_alerts_to },
639             subject => "404 - " . $req->uri,
640             text_body => "404 - " . $req->uri . "\n\nReferrer: " . ( $req->referer || 'None' ) . "\n\n" . Dumper( $req ) . "\n\n" . Dumper( \%ENV ),
641             );
642             }
643              
644             my $res = $req->new_response;
645              
646             $res->status( 404 );
647              
648             $res->content_type('text/html; charset=utf-8');
649              
650             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash->{ app }->templates );
651              
652             $log->debug("Processing template: " . $stash->{ app }->templates . "/" . $stash->{ page }->{ template } );
653              
654             my $body = '';
655              
656             $tt->process( $stash->{ page }->{ template }, $stash, \$body ) or $log->debug( $tt->error );
657              
658             $res->content_type('text/html; charset=utf-8');
659              
660             $res->body( encode( "UTF-8", $body ) );
661              
662             return $res->finalize;
663             }
664              
665             sub _reload_config
666             {
667             my ( $self, %args ) = @_;
668              
669             my $log = $args{ log };
670              
671             $log->debug( "Opening config file: " . $self->filename );
672              
673             my $string = read_file( $self->filename, { binmode => ':utf8' } );
674              
675             my $config = undef;
676              
677             eval {
678             my $json = JSON->new;
679              
680             $json->relaxed( 1 );
681              
682             $self->config( $json->decode( $string ) );
683             };
684              
685             $log->debug( $@ ) if $@;
686              
687             return $self;
688             }
689              
690             sub _send_email
691             {
692             my ( $self, %args ) = @_;
693              
694             if ( $args{ to } )
695             {
696             Email::Stuffer->from( $args{ from } )
697             ->to( $args{ to } )
698             ->subject( $args{ subject } )
699             ->text_body( $args{ text_body } )
700             ->send;
701             }
702              
703             return $self;
704             }
705              
706             =head1 TODO
707              
708             Deep recursion for page/path lookups.
709              
710             Deep recursion for sitemap.
711              
712             Cleanup deeper recursion in pages with parents.
713              
714             Searching, somehow, of some set of templates.
715              
716             =head1 AUTHOR
717              
718             Rob Brown, C<< <rob at intelcompute.com> >>
719              
720             =head1 LICENSE AND COPYRIGHT
721              
722             Copyright 2014 Rob Brown.
723              
724             This program is free software; you can redistribute it and/or modify it
725             under the terms of either: the GNU General Public License as published
726             by the Free Software Foundation; or the Artistic License.
727              
728             See http://dev.perl.org/licenses/ for more information.
729              
730             =cut
731              
732             1;
733