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