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   15400 use 5.006;
  1         4  
  1         47  
4              
5 1     1   237 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.04
30              
31             =cut
32              
33             our $VERSION = '0.04';
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             $config = decode_json $string;
255             };
256              
257             if ( $@ )
258             {
259             _print_red( "[ FAIL ]\n" );
260             print "Config file error...\n" . $@ . "Exiting...\n";
261             exit;
262             }
263              
264             ###################################
265             # basic checks on the config file #
266             ###################################
267              
268             if ( ! $config->{ pages } )
269             {
270             _print_red( "[ FAIL ]\n" );
271             print "'pages' attribute missing at top level.\nExiting...\n";
272             exit;
273             }
274              
275             if ( ref $config->{ pages } ne 'ARRAY' )
276             {
277             _print_red( "[ FAIL ]\n" );
278             print "'pages' attribute is not a list.\nExiting...\n";
279             exit;
280             }
281              
282             if ( scalar @{ $config->{ pages } } == 0 )
283             {
284             _print_organge( "[ISSUE]\n" );
285             print "No 'pages' defined in config, this will result in a 404 for all requests.\n";
286             }
287              
288             my %paths = ();
289              
290             foreach my $each_page ( @{ $config->{ pages } } )
291             {
292             if ( ! $each_page->{ path } )
293             {
294             _print_red( "[ FAIL ]\n" );
295             print "'path' attribute missing for page..." . ( Dumper $each_page );
296             exit;
297             }
298              
299             if ( ! $each_page->{ template } )
300             {
301             _print_red( "[ FAIL ]\n" );
302             print "'template' attribute missing for page..." . ( Dumper $each_page );
303             exit;
304             }
305              
306             if ( exists $paths{ $each_page->{ path } } )
307             {
308             _print_red( "[ FAIL ]\n" );
309             print "Path '" . $each_page->{ path } . "' found more than once.\nExiting...\n";
310             exit;
311             }
312              
313             $paths{ $each_page->{ path } } = 1;
314             }
315              
316             _print_green( "[ OK ]\n" );
317              
318             return $self;
319             }
320              
321             sub _print_green
322             {
323             my $string = shift;
324             print color 'bold green';
325             print $string;
326             print color 'reset';
327             }
328              
329             sub _print_orange
330             {
331             my $string = shift;
332             print color 'bold orange';
333             print $string;
334             print color 'reset';
335             }
336              
337             sub _print_red
338             {
339             my $string = shift;
340             print color 'bold red';
341             print $string;
342             print color 'reset';
343             }
344              
345             =head3 stop
346              
347             Stops the FastCGI daemon.
348              
349             =cut
350              
351             sub stop
352             {
353             my $self = shift;
354              
355             if ( ! -e $self->_pid )
356             {
357             return $self;
358             }
359            
360             open( my $fh, "<", $self->_pid ) or die "Cannot open pidfile: $!";
361              
362             my @pids = <$fh>;
363              
364             close $fh;
365              
366             chomp( $pids[0] );
367              
368             print "Killing pid $pids[0] ...\n";
369              
370             kill 15, $pids[0];
371              
372             return $self;
373             }
374              
375             =head3 restart
376              
377             Restarts the FastCGI daemon, with a 1 second delay between stopping and starting.
378              
379             =cut
380              
381             sub restart
382             {
383             my $self = shift;
384            
385             $self->stop;
386              
387             sleep 1;
388              
389             $self->start;
390              
391             return $self;
392             }
393              
394             =head1 CONFIGURATION
395              
396             The app should be a simple Perl script in a folder with the following structure:
397              
398             app.pl # see the synopsis
399             app.json # see below
400             app.pid # generated, to control the process
401             app.sock # generated, to accept incoming FastCGI connections
402             logs/
403             templates/
404             404.tt
405              
406             The config file is read for each and every request, this makes adding new pages easy, without the need to restart the application.
407              
408             The config file should be placed in the C<base> directory of your application.
409              
410             See the C<examples> directory for a sample JSON config file, something like the following...
411              
412             {
413             "pages" : [
414             {
415             "path" : "/",
416             "template":"index.tt",
417             ...
418             },
419             ...
420             ]
421             ...
422             "send_alerts_from":"The Example App <no-reply@example.com>",
423             "send_404_alerts_to":"you@example.com",
424             ...
425             }
426              
427             The entire config hash is available in all templates via C<[% app.config %]>, there are only a couple of mandatory/reserved attributes.
428              
429             The mandatory field in the config is C<pages>, an array of pages.
430              
431             Each C<page> should contain a C<path> (for URL matching) and C<template> to render.
432              
433             All other fields are completely up to you, to fit your requirements.
434              
435             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>.
436              
437             If no page is found, the template C<404.tt> will be rendered, make sure you have this file ready in the templates directory.
438              
439             The C<page> object is available in the rendered template, eg, C<[% page.path %]>
440              
441             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.
442              
443             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.
444              
445             =cut
446              
447             # returns a code-ref for the FCGI handler/server.
448              
449             sub _handler
450             {
451             my $self = shift;
452              
453             return sub {
454              
455             ##############
456             # initialise #
457             ##############
458              
459             my $req = Plack::Request->new( shift );
460              
461             my %stash = (
462             app => $self,
463             req => $req,
464             now => DateTime->now,
465             started => join( '.', gettimeofday ),
466             );
467              
468             my $log = Log::AutoDump->new( base_dir => $stash{ app }->base . '/logs', filename => 'app.log' );
469              
470             $log->debug("Started");
471              
472             my $path = $req->uri->path;
473              
474             $log->debug( "Requested path: " . $path );
475              
476             $stash{ app }->_reload_config( log => $log );
477              
478             ###############
479             # sitemap xml #
480             ###############
481              
482             if ( $path eq '/sitemap.xml' )
483             {
484             return $stash{ app }->_sitemap( log => $log, req => $req, stash => \%stash );
485             }
486              
487             ##########################################################################
488             # find a matching 'page' from the config that matches the requested path #
489             ##########################################################################
490              
491             # need to do proper recursion here
492              
493             foreach my $each_page ( @{ $stash{ app }->{ config }->{ pages } } )
494             {
495             if ( $path eq $each_page->{ path } )
496             {
497             $stash{ page } = $each_page;
498              
499             last;
500             }
501              
502             if ( ref $each_page->{ pages } eq 'ARRAY' )
503             {
504             foreach my $each_sub_page ( @{ $each_page->{ pages } } )
505             {
506             if ( $path eq $each_sub_page->{ path } )
507             {
508             $stash{ page } = $each_sub_page;
509              
510             $stash{ page }->{ parents } = [];
511            
512             push @{ $stash{ page }->{ parents } }, $each_page;
513            
514             last;
515             }
516             }
517             }
518             }
519              
520             $log->debug( "Matching page found in config...", $stash{ page } ) if exists $stash{ page };
521              
522             #######
523             # 404 #
524             #######
525            
526             if ( ! exists $stash{ page } )
527             {
528             return $stash{ app }->_404( log => $log, req => $req, stash => \%stash );
529             }
530              
531             ##############################
532             # responding with a template #
533             ##############################
534              
535             my $res = $req->new_response;
536              
537             $res->status( 200 );
538              
539             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash{ app }->templates );
540              
541             $log->debug("Processing template: " . $stash{ app }->templates . "/" . $stash{ page }->{ template } );
542              
543             my $body = '';
544              
545             $tt->process( $stash{ page }->{ template }, \%stash, \$body ) or $log->debug( $tt->error );
546              
547             $res->content_type('text/html; charset=utf-8');
548              
549             $res->body( encode( "UTF-8", $body ) );
550              
551             #########
552             # stats #
553             #########
554              
555             $stash{ took } = join( '.', gettimeofday ) - $stash{ started };
556            
557             $log->debug( "The stash contains...", \%stash );
558            
559             $log->debug( "Took " . sprintf("%.5f", $stash{ took } ) . " seconds");
560              
561             #######################################
562             # cleanup (circular references, etc.) #
563             #######################################
564              
565             # need to do deep pages too!
566              
567             delete $stash{ page }->{ parents } if exists $stash{ page };
568              
569             return $res->finalize;
570             }
571             }
572              
573             sub _sitemap
574             {
575             my ( $self, %args ) = @_;
576              
577             my $log = $args{ log };
578             my $req = $args{ req };
579             my $stash = $args{ stash };
580              
581             my $base = ($req->env->{'psgi.url_scheme'} || "http") .
582             "://" . ($req->env->{HTTP_HOST} || (($req->env->{SERVER_NAME} || "") . ":" . ($req->env->{SERVER_PORT} || 80)));
583              
584             my $sitemap = '<?xml version="1.0" encoding="UTF-8"?>';
585              
586             $sitemap .= "\n";
587              
588             $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">';
589              
590             $sitemap .= "\n";
591              
592             # need to do proper recursion here
593            
594             foreach my $each_page ( @{ $stash->{ config }->{ pages } } )
595             {
596             $sitemap .= "<url><loc>" . $base . $each_page->{ path } . "</loc></url>\n";
597              
598             if ( ref $each_page->{ pages } eq 'ARRAY' )
599             {
600             foreach my $each_sub_page ( @{ $each_page->{ pages } } )
601             {
602             $sitemap .= "<url><loc>" . $base . $each_sub_page->{ path } . "</loc></url>\n";
603             }
604             }
605             }
606            
607             $sitemap .= "</urlset>\n";
608              
609             my $res = $req->new_response;
610              
611             $res->status(200);
612              
613             $res->content_type('application/xml; charset=utf-8');
614            
615             $res->body( encode( "UTF-8", $sitemap ) );
616              
617             return $res->finalize;
618             }
619              
620             sub _404
621             {
622             my ( $self, %args ) = @_;
623              
624             my $log = $args{ log };
625             my $req = $args{ req };
626             my $stash = $args{ stash };
627              
628             $stash->{ page } = { template => '404.tt' };
629              
630             if ( $stash->{ config }->{ send_alerts_from } && $stash->{ config }->{ send_404_alerts_to } )
631             {
632             $stash->{ app }->_send_email(
633             from => $stash->{ config }->{ send_alerts_from },
634             to => $stash->{ config }->{ send_404_alerts_to },
635             subject => "404 - " . $req->uri,
636             text_body => "404 - " . $req->uri . "\n\nReferrer: " . ( $req->referer || 'None' ) . "\n\n" . Dumper( $req ) . "\n\n" . Dumper( \%ENV ),
637             );
638             }
639              
640             my $res = $req->new_response;
641              
642             $res->status( 404 );
643              
644             $res->content_type('text/html; charset=utf-8');
645              
646             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash->{ app }->templates );
647              
648             $log->debug("Processing template: " . $stash->{ app }->templates . "/" . $stash->{ page }->{ template } );
649              
650             my $body = '';
651              
652             $tt->process( $stash->{ page }->{ template }, $stash, \$body ) or $log->debug( $tt->error );
653              
654             $res->content_type('text/html; charset=utf-8');
655              
656             $res->body( encode( "UTF-8", $body ) );
657              
658             return $res->finalize;
659             }
660              
661             sub _reload_config
662             {
663             my ( $self, %args ) = @_;
664              
665             my $log = $args{ log };
666              
667             $log->debug( "Opening config file: " . $self->filename );
668              
669             my $string = read_file( $self->filename, { binmode => ':utf8' } );
670              
671             my $config = undef;
672              
673             eval {
674             $self->config( decode_json $string );
675             };
676              
677             $log->debug( $@ ) if $@;
678              
679             return $self;
680             }
681              
682             sub _send_email
683             {
684             my ( $self, %args ) = @_;
685              
686             if ( $args{ to } )
687             {
688             Email::Stuffer->from( $args{ from } )
689             ->to( $args{ to } )
690             ->subject( $args{ subject } )
691             ->text_body( $args{ text_body } )
692             ->send;
693             }
694              
695             return $self;
696             }
697              
698             =head1 TODO
699              
700             Deep recursion for page/path lookups.
701              
702             Deep recursion for sitemap.
703              
704             Cleanup deeper recursion in pages with parents.
705              
706             Searching, somehow, of some set of templates.
707              
708             =head1 AUTHOR
709              
710             Rob Brown, C<< <rob at intelcompute.com> >>
711              
712             =head1 LICENSE AND COPYRIGHT
713              
714             Copyright 2014 Rob Brown.
715              
716             This program is free software; you can redistribute it and/or modify it
717             under the terms of either: the GNU General Public License as published
718             by the Free Software Foundation; or the Artistic License.
719              
720             See http://dev.perl.org/licenses/ for more information.
721              
722             =cut
723              
724             1;
725