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   28277 use 5.006;
  1         6  
  1         52  
4              
5 1     1   307 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.14
32              
33             =cut
34              
35             our $VERSION = '0.14';
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             website.pid # generated, to allow stopping of the process
394             website.sock # generated, to accept incoming FastCGI connections
395             /logs
396             /templates
397             404.tt
398              
399             The config file is read for each and every request, this makes adding new pages easy, without the need to restart the application.
400              
401             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.
402              
403             See the C<examples> directory for a sample JSON config file, something like the following...
404              
405             {
406             "pages" : [
407             {
408             "path" : "/",
409             "template":"index.tt",
410             ...
411             },
412             ...
413             ]
414             ...
415             "send_alerts_from":"The Example Website <no-reply@example.com>",
416             "send_404_alerts_to":"you@example.com",
417             ...
418             }
419              
420             The entire config hash is available in all templates via C<[% config %]>, there are only a couple of mandatory/reserved attributes.
421              
422             The mandatory field in the config is C<pages>, which is an array of JSON objects.
423              
424             Each C<page> object should contain a C<path> (for URL matching) and C<template> to render.
425              
426             All other fields are completely up to you, to fit your requirements.
427              
428             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>.
429              
430             If no page is found, the template C<404.tt> will be rendered, make sure you have this file ready in the templates directory.
431              
432             The C<page> object is available in the rendered template, eg, C<[% page.path %]>
433              
434             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.
435              
436             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.
437              
438             =cut
439              
440             # returns a code-ref for the FCGI handler/server.
441              
442             sub _handler
443             {
444             my $self = shift;
445              
446             return sub {
447              
448             ##############
449             # initialise #
450             ##############
451              
452             my $req = Plack::Request->new( shift );
453              
454             my %stash = (
455             website => $self,
456             req => $req,
457             now => DateTime->now,
458             started => join( '.', gettimeofday ),
459             );
460              
461             my $log = Log::AutoDump->new( base_dir => $stash{ website }->base . '/logs', filename => 'website.log' );
462              
463             $log->debug("Started");
464              
465             my $path = $req->uri->path;
466              
467             $log->debug( "Requested path: " . $path );
468              
469             $stash{ config } = $stash{ website }->_get_config( log => $log );
470              
471             ###############
472             # sitemap xml #
473             ###############
474              
475             if ( $path eq '/sitemap.xml' )
476             {
477             return $stash{ website }->_sitemap( log => $log, req => $req, stash => \%stash );
478             }
479              
480             ##########################################################################
481             # find a matching 'page' from the config that matches the requested path #
482             ##########################################################################
483              
484             # need to do proper recursion here
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...", $stash{ page } ) 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             ##############################
525             # responding with a template #
526             ##############################
527              
528             my $res = $req->new_response;
529              
530             $res->status( 200 );
531              
532             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash{ website }->templates );
533              
534             $log->debug("Processing template: " . $stash{ website }->templates . "/" . $stash{ page }->{ template } );
535              
536             my $body = '';
537              
538             $tt->process( $stash{ page }->{ template }, \%stash, \$body ) or $log->debug( $tt->error );
539              
540             $res->content_type('text/html; charset=utf-8');
541              
542             $res->body( encode( "UTF-8", $body ) );
543              
544             #########
545             # stats #
546             #########
547              
548             $stash{ took } = join( '.', gettimeofday ) - $stash{ started };
549            
550             $log->debug( "The stash contains...", \%stash );
551            
552             $log->debug( "Took " . sprintf("%.5f", $stash{ took } ) . " seconds");
553              
554             #######################################
555             # cleanup (circular references, etc.) #
556             #######################################
557              
558             # need to do deep pages too!
559              
560             delete $stash{ page }->{ parents } if exists $stash{ page };
561              
562             return $res->finalize;
563             }
564             }
565              
566             sub _sitemap
567             {
568             my ( $self, %args ) = @_;
569              
570             my $log = $args{ log };
571             my $req = $args{ req };
572             my $stash = $args{ stash };
573              
574             my $base = ($req->env->{'psgi.url_scheme'} || "http") .
575             "://" . ($req->env->{HTTP_HOST} || (($req->env->{SERVER_NAME} || "") . ":" . ($req->env->{SERVER_PORT} || 80)));
576              
577             my $sitemap = '<?xml version="1.0" encoding="UTF-8"?>';
578              
579             $sitemap .= "\n";
580              
581             $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">';
582              
583             $sitemap .= "\n";
584              
585             # need to do proper recursion here
586            
587             foreach my $each_page ( @{ $stash->{ config }->{ pages } } )
588             {
589             $sitemap .= "<url><loc>" . $base . $each_page->{ path } . "</loc></url>\n";
590              
591             if ( ref $each_page->{ pages } eq 'ARRAY' )
592             {
593             foreach my $each_sub_page ( @{ $each_page->{ pages } } )
594             {
595             $sitemap .= "<url><loc>" . $base . $each_sub_page->{ path } . "</loc></url>\n";
596             }
597             }
598             }
599            
600             $sitemap .= "</urlset>\n";
601              
602             my $res = $req->new_response;
603              
604             $res->status(200);
605              
606             $res->content_type('application/xml; charset=utf-8');
607            
608             $res->body( encode( "UTF-8", $sitemap ) );
609              
610             return $res->finalize;
611             }
612              
613             sub _404
614             {
615             my ( $self, %args ) = @_;
616              
617             my $log = $args{ log };
618             my $req = $args{ req };
619             my $stash = $args{ stash };
620              
621             $stash->{ page } = { template => '404.tt' };
622              
623             if ( $stash->{ config }->{ send_alerts_from } && $stash->{ config }->{ send_404_alerts_to } )
624             {
625             $stash->{ website }->_send_email(
626             from => $stash->{ config }->{ send_alerts_from },
627             to => $stash->{ config }->{ send_404_alerts_to },
628             subject => "404 - " . $req->uri,
629             text_body => "404 - " . $req->uri . "\n\nReferrer: " . ( $req->referer || 'None' ) . "\n\n" . Dumper( $req ) . "\n\n" . Dumper( \%ENV ),
630             );
631             }
632              
633             my $res = $req->new_response;
634              
635             $res->status( 404 );
636              
637             $res->content_type('text/html; charset=utf-8');
638              
639             my $tt = Template->new( ENCODING => 'UTF-8', INCLUDE_PATH => $stash->{ website }->templates );
640              
641             $log->debug("Processing template: " . $stash->{ website }->templates . "/" . $stash->{ page }->{ template } );
642              
643             my $body = '';
644              
645             $tt->process( $stash->{ page }->{ template }, $stash, \$body ) or $log->debug( $tt->error );
646              
647             $res->content_type('text/html; charset=utf-8');
648              
649             $res->body( encode( "UTF-8", $body ) );
650              
651             return $res->finalize;
652             }
653              
654             sub _get_config
655             {
656             my ( $self, %args ) = @_;
657              
658             my $log = $args{ log };
659              
660             my $filename = $self->base . '/website.json';
661              
662             $log->debug( "Opening config file: " . $filename );
663              
664             my $string = read_file( $filename, { binmode => ':utf8' } );
665              
666             my $config = undef;
667              
668             eval {
669             $config = decode_json $string;
670             };
671              
672             $log->debug( $@ ) if $@;
673              
674             return $config;
675             }
676              
677             sub _send_email
678             {
679             my ( $self, %args ) = @_;
680              
681             if ( $args{ to } )
682             {
683             Email::Stuffer->from( $args{ from } )
684             ->to( $args{ to } )
685             ->subject( $args{ subject } )
686             ->text_body( $args{ text_body } )
687             ->send;
688             }
689              
690             return $self;
691             }
692              
693             =head1 TODO
694              
695             More documentation.
696              
697             Deep recursion for page/path lookups.
698              
699             Deep recursion for sitemap.
700              
701             Cleanup deeper recursion in pages with parents.
702              
703             Searching, somehow, of some set of templates.
704              
705             Include filename of config file in website/self object?
706              
707             Include content of config in website/self object? Reload on change to be really snazzy.
708              
709             =head1 AUTHOR
710              
711             Rob Brown, C<< <rob at intelcompute.com> >>
712              
713             =head1 BUGS
714              
715             Please report any bugs or feature requests to C<bug-lavoco-website at rt.cpan.org>, or through
716             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Lavoco-Website>. I will be notified, and then you will
717             automatically be notified of progress on your bug as I make changes.
718              
719             =head1 SUPPORT
720              
721             You can find documentation for this module with the perldoc command.
722              
723             perldoc Lavoco::Website
724              
725              
726             You can also look for information at:
727              
728             =over 4
729              
730             =item * RT: CPAN's request tracker (report bugs here)
731              
732             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Lavoco-Website>
733              
734             =item * AnnoCPAN: Annotated CPAN documentation
735              
736             L<http://annocpan.org/dist/Lavoco-Website>
737              
738             =item * CPAN Ratings
739              
740             L<http://cpanratings.perl.org/d/Lavoco-Website>
741              
742             =item * Search CPAN
743              
744             L<http://search.cpan.org/dist/Lavoco-Website/>
745              
746             =back
747              
748             =head1 LICENSE AND COPYRIGHT
749              
750             Copyright 2014 Rob Brown.
751              
752             This program is free software; you can redistribute it and/or modify it
753             under the terms of either: the GNU General Public License as published
754             by the Free Software Foundation; or the Artistic License.
755              
756             See http://dev.perl.org/licenses/ for more information.
757              
758             =cut
759              
760             1;
761