File Coverage

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