File Coverage

blib/lib/Lavoco/Web/Editor.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::Editor;
2              
3 1     1   14802 use 5.006;
  1         4  
  1         32  
4              
5 1     1   207 use Moose;
  0            
  0            
6              
7             use Data::Dumper;
8             use DateTime;
9             use Digest::SHA1 qw(sha1_hex);
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::Editor - FastCGI app to edit flat-files.
26              
27             =head1 VERSION
28              
29             Version 0.05
30              
31             =cut
32              
33             our $VERSION = '0.05';
34              
35             $VERSION = eval $VERSION;
36              
37             =head1 SYNOPSIS
38              
39             This application was originally designed to aid in the editing of basic templates for a L<Lavoco::Web::App> project.
40              
41             use Lavoco::Web::Editor;
42            
43             my $editor = Lavoco::Web::Editor->new;
44            
45             my $action = lc( $ARGV[0] ); # (start|stop|restart)
46            
47             $editor->$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 editor 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 _pid => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build__pid' );
66             has _socket => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build__socket' );
67             has filename => ( is => 'rw', isa => 'Str', lazy => 1, builder => '_build_filename' );
68             has config => ( is => 'rw', isa => 'HashRef' );
69              
70             sub _build__base
71             {
72             return $Bin;
73             }
74              
75             sub _build__pid
76             {
77             my $self = shift;
78              
79             return $self->_base . '/editor.pid';
80             }
81              
82             sub _build__socket
83             {
84             my $self = shift;
85              
86             return $self->_base . '/editor.sock';
87             }
88              
89             sub _build_filename
90             {
91             my $self = shift;
92              
93             return $self->_base . '/editor.json';
94             }
95              
96             =head3 processes
97              
98             Number of FastCGI process to spawn, 5 by default.
99              
100             =head3 filename
101              
102             Filename for the config file, default is C<editor.json> and only JSON is currently supported.
103              
104             =head3 config
105              
106             The loaded config as a hash-reference.
107              
108             =head2 Instance Methods
109              
110             =head3 start
111              
112             Starts the FastCGI daemon. Performs basic checks of your environment and config, dies if there's a problem.
113              
114             =cut
115              
116             sub start
117             {
118             my $self = shift;
119              
120             if ( -e $self->_pid )
121             {
122             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";
123            
124             return $self;
125             }
126              
127             $self->_init;
128              
129             print "Building FastCGI engine...\n";
130            
131             my $server = Plack::Handler::FCGI->new(
132             nproc => $self->processes,
133             listen => [ $self->_socket ],
134             pid => $self->_pid,
135             detach => 1,
136             );
137            
138             $server->run( $self->_handler );
139             }
140              
141             sub _init
142             {
143             my ( $self, %args ) = @_;
144              
145             ###############################
146             # make sure there's a log dir #
147             ###############################
148              
149             printf( "%-50s", "Checking logs directory");
150              
151             my $log_dir = $self->_base . '/logs';
152              
153             if ( ! -e $log_dir || ! -d $log_dir )
154             {
155             _print_red( "[ FAIL ]\n" );
156             print $log_dir . " does not exist, or it's not a folder.\nExiting...\n";
157             exit;
158             }
159              
160             _print_green( "[ OK ]\n" );
161              
162             ########################
163             # load the config file #
164             ########################
165              
166             printf( "%-50s", "Checking config");
167              
168             if ( ! -e $self->filename )
169             {
170             _print_red( "[ FAIL ]\n" );
171             print $self->filename . " does not exist.\nExiting...\n";
172             exit;
173             }
174              
175             my $string = read_file( $self->filename, { binmode => ':utf8' } );
176              
177             my $config = undef;
178              
179             eval {
180             $config = decode_json $string;
181             };
182              
183             if ( $@ )
184             {
185             _print_red( "[ FAIL ]\n" );
186             print "Config file error...\n" . $@ . "Exiting...\n";
187             exit;
188             }
189              
190             ###################################
191             # basic checks on the config file #
192             ###################################
193              
194             if ( $config->{ password } && ! exists $config->{ salt } )
195             {
196             _print_red( "[ FAIL ]\n" );
197             print "'password' attribute but no 'salt'.\nExiting...\n";
198             exit;
199             }
200              
201              
202             if ( ! $config->{ files } )
203             {
204             _print_red( "[ FAIL ]\n" );
205             print "'files' attribute missing at top level.\nExiting...\n";
206             exit;
207             }
208              
209             if ( ref $config->{ files } ne 'ARRAY' )
210             {
211             _print_red( "[ FAIL ]\n" );
212             print "'files' attribute is not a list.\nExiting...\n";
213             exit;
214             }
215              
216             if ( scalar @{ $config->{ files } } == 0 )
217             {
218             _print_organge( "[ISSUE]\n" );
219             print "No 'files' defined in config, so no files to edit.\n";
220             }
221              
222             _print_green( "[ OK ]\n" );
223              
224             return $self;
225             }
226              
227             sub _print_green
228             {
229             my $string = shift;
230             print color 'bold green';
231             print $string;
232             print color 'reset';
233             }
234              
235             sub _print_orange
236             {
237             my $string = shift;
238             print color 'bold orange';
239             print $string;
240             print color 'reset';
241             }
242              
243             sub _print_red
244             {
245             my $string = shift;
246             print color 'bold red';
247             print $string;
248             print color 'reset';
249             }
250              
251             =head3 stop
252              
253             Stops the FastCGI daemon.
254              
255             =cut
256              
257             sub stop
258             {
259             my $self = shift;
260              
261             if ( ! -e $self->_pid )
262             {
263             return $self;
264             }
265            
266             open( my $fh, "<", $self->_pid ) or die "Cannot open pidfile: $!";
267              
268             my @pids = <$fh>;
269              
270             close $fh;
271              
272             chomp( $pids[0] );
273              
274             print "Killing pid $pids[0] ...\n";
275              
276             kill 15, $pids[0];
277              
278             return $self;
279             }
280              
281             =head3 restart
282              
283             Restarts the FastCGI daemon, with a 1 second delay between stopping and starting.
284              
285             =cut
286              
287             sub restart
288             {
289             my $self = shift;
290            
291             $self->stop;
292              
293             sleep 1;
294              
295             $self->start;
296              
297             return $self;
298             }
299              
300             =head1 CONFIGURATION
301              
302             The editor app should be a simple Perl script in a folder with the following structure:
303              
304             editor.pl # see the synopsis
305             editor.json # config, see below
306             editor.pid # generated, to control the process
307             editor.sock # generated, to accept incoming FastCGI connections
308             logs/
309            
310             The config file is read for each and every request, so you can reasonably enable editing of the editors own config file.
311              
312             See the C<examples> directory for a sample JSON config file, similar to the following...
313              
314             {
315             "folders" : [
316             "templates/content/organic",
317             "templates/content/store"
318             ],
319             "files" : [
320             "app.json",
321             "site/style.css"
322             ],
323             "password" : "foo",
324             "salt" : "abc123"
325             }
326              
327             Two fields which drive the editor are C<folders> and C<files>, each of which is an array of paths, all relative to the base directory of the editor script.
328              
329             All visible files in the C<folders> are editable, but not sub-directories, you need to add those separately. The editor can also create new files in each folder.
330              
331             Files in the C<files> list are editable, but the editor can not create new files in their respective containing directories.
332              
333             If there is a defined C<password> in the config, then this will be requested before a user can access the index page (listing all files that can be edited).
334              
335             When using a C<password>, a C<salt> is also required, just create a random string, it's simply concatenated to the password before SHA-hashing and setting as a cookie.
336              
337             =cut
338              
339             # returns a code-ref for the FCGI handler/server.
340              
341             sub _handler
342             {
343             my $self = shift;
344              
345             return sub {
346              
347             ##############
348             # initialise #
349             ##############
350              
351             my $req = Plack::Request->new( shift );
352              
353             my $res = $req->new_response;
354              
355             my %stash = (
356             app => $self,
357             req => $req,
358             now => DateTime->now,
359             started => join( '.', gettimeofday ),
360             );
361              
362             my $log = Log::AutoDump->new( base_dir => $stash{ app }->_base . '/logs', filename => 'editor.log' );
363              
364             $log->debug("Started");
365              
366             my $path = $req->uri->path;
367              
368             $log->debug( "Requested path: " . $path );
369              
370             $stash{ app }->_reload_config( log => $log );
371              
372             ###############################
373             # check for password required #
374             ###############################
375              
376             my $template = 'login.tt';
377              
378             if ( ! exists $stash{ app }->config->{ password } )
379             {
380             $log->debug( "No password set, so going straight to index.tt" );
381              
382             $template = 'index.tt';
383             }
384             else
385             {
386             if ( exists $req->parameters->{ password } )
387             {
388             if ( $req->parameters->{ password } eq $stash{ app }->config->{ password } )
389             {
390             $res->cookies->{ password } = sha1_hex( $stash{ app }->config->{ salt } . $stash{ app }->config->{ password } );
391              
392             $template = 'index.tt';
393             }
394             else
395             {
396             $res->cookies->{ password } = '';
397             }
398             }
399             elsif ( $req->cookies->{ password } )
400             {
401             $log->debug( "We have a cookie for a password" );
402              
403             if ( $req->cookies->{ password } eq sha1_hex( $stash{ app }->config->{ salt } . $stash{ app }->config->{ password } ) )
404             {
405             $log->debug( "Cookie matches sha1 hash" );
406              
407             $template = 'index.tt';
408             }
409             }
410             }
411              
412             if ( $template ne 'login.tt' )
413             {
414             my @folders = ();
415             my @files = ();
416              
417             ###########
418             # folders #
419             ###########
420              
421             if ( exists $stash{ app }->config->{ folders } && @{ $stash{ app }->config->{ folders } } )
422             {
423             foreach my $folder ( @{ $stash{ app }->config->{ folders } } )
424             {
425             $folder =~ s/^\///g; # remove leading slashes
426             $folder =~ s/\/$//g; # remove trailing slashes
427              
428             $log->debug( "Processing folder: " . $folder );
429              
430             my $path = $stash{ app }->_base . '/' . $folder;
431              
432             if ( -d $path )
433             {
434             my %folder = ( path => $folder, files => [ ] );
435              
436             opendir( my $dh, $path ) || $log->debug("Can't opendir $path: $!");
437              
438             push @{ $folder{ files } }, sort { $a cmp $b } grep { ! -d ( $stash{ app }->_base . '/' . $folder . '/' . $_ ) } grep { $_ !~ /^\./ } readdir( $dh );
439              
440             closedir( $dh );
441              
442             push @folders, \%folder;
443             }
444             }
445             }
446              
447             #########
448             # files #
449             #########
450              
451             if ( exists $stash{ app }->config->{ files } && @{ $stash{ app }->config->{ files } } )
452             {
453             foreach my $file ( @{ $stash{ app }->config->{ files } } )
454             {
455             $file =~ s/^\///g; # remove leading slashes
456             $file =~ s/\/$//g; # remove trailing slashes
457              
458             $log->debug( "Processing file: " . $file );
459              
460             my $path = $stash{ app }->_base . '/' . $file;
461              
462             if ( -f $path )
463             {
464             push @files, $file;
465             }
466             }
467             }
468              
469             ######################################
470             # if we've requested a file, edit it #
471             ######################################
472              
473             if ( exists $req->parameters->{ folder } )
474             {
475             foreach my $folder ( @folders )
476             {
477             next if $folder->{ path } ne $req->parameters->{ folder };
478              
479             $stash{ folder } = $req->parameters->{ folder };
480              
481             $template = 'edit.tt';
482              
483             if ( $req->parameters->{ file } )
484             {
485             foreach my $file ( @{ $folder->{ files } } )
486             {
487             next if $file ne $req->parameters->{ file };
488              
489             $stash{ file } = $req->parameters->{ file };
490              
491             if ( ! exists $req->parameters->{ content } )
492             {
493             $log->debug( "Reading content of " . $stash{ app }->_base . '/' . $folder->{ path } . '/' . $file );
494              
495             $stash{ content } = read_file( $stash{ app }->_base . '/' . $folder->{ path } . '/' . $file, { binmode => ':utf8' } );
496             }
497             }
498             }
499              
500             if ( exists $req->parameters->{ content } )
501             {
502             write_file( $stash{ app }->_base . '/' . $folder->{ path } . '/' . $req->parameters->{ file }, { binmode => ':utf8' }, $req->parameters->{ content } );
503              
504             $stash{ file } = $req->parameters->{ file };
505              
506             $stash{ content } = $req->parameters->{ content };
507             }
508             }
509             }
510             elsif ( exists $req->parameters->{ file } )
511             {
512             foreach my $file ( @files )
513             {
514             next if $file ne $req->parameters->{ file };
515              
516             $stash{ file } = $req->parameters->{ file };
517              
518             $template = 'edit.tt';
519              
520             if ( ! exists $req->parameters->{ content } )
521             {
522             $log->debug( "Reading content of " . $stash{ app }->_base . '/' . $file );
523              
524             $stash{ content } = read_file( $stash{ app }->_base . '/' . $file, { binmode => ':utf8' } );
525             }
526             else
527             {
528             write_file( $stash{ app }->_base . '/' . $file, { binmode => ':utf8' }, $req->parameters->{ content } );
529              
530             $stash{ file } = $req->parameters->{ file };
531              
532             $stash{ content } = $req->parameters->{ content };
533             }
534             }
535             }
536              
537             $stash{ folders } = \@folders;
538             $stash{ files } = \@files;
539             }
540              
541             ##############################
542             # responding with a template #
543             ##############################
544              
545             $res->status( 200 );
546              
547             my $tt = Template->new( ENCODING => 'UTF-8' );
548              
549             $log->debug("Processing template: " . $template );
550              
551             my $body = '';
552              
553             $tt->process( $stash{ app }->_template_tt( $template ), \%stash, \$body ) or $log->debug( $tt->error );
554              
555             $res->content_type('text/html; charset=utf-8');
556              
557             $res->body( encode( "UTF-8", $body ) );
558              
559             #########
560             # stats #
561             #########
562              
563             $stash{ took } = join( '.', gettimeofday ) - $stash{ started };
564            
565             $log->debug( "Took " . sprintf("%.5f", $stash{ took } ) . " seconds");
566              
567             return $res->finalize;
568             }
569             }
570              
571             sub _reload_config
572             {
573             my ( $self, %args ) = @_;
574              
575             my $log = $args{ log };
576              
577             $log->debug( "Opening config file: " . $self->filename );
578              
579             my $string = read_file( $self->filename, { binmode => ':utf8' } );
580              
581             my $config = undef;
582              
583             eval {
584             $self->config( decode_json $string );
585             };
586              
587             $log->debug( $@ ) if $@;
588              
589             return $self;
590             }
591              
592             # returns a scalar-ref to feed into TT
593              
594             sub _template_tt
595             {
596             my ( $self, $template ) = @_;
597              
598             my $string = '';
599              
600             if ( $template eq 'login.tt' )
601             {
602             $string = <<EOF;
603             <html>
604             <head>
605             <style>
606             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
607             </style>
608             </head>
609              
610             <body>
611              
612             <h1>Website Content Editor</h2>
613              
614             <form action="/" method="POST">
615             <input type="text" name="password" value="" style="float: left; clear: both;">
616              
617             <input type="submit" value="Login" style="float: left; clear: both;">
618             </form>
619              
620             </body>
621              
622             </html>
623              
624             EOF
625             }
626             elsif ( $template eq 'index.tt' )
627             {
628             $string = <<EOF;
629             <html>
630             <head>
631             <style>
632             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
633             ul li { margin-top: 5px; }
634             </style>
635             </head>
636              
637             <body>
638              
639             <h1>Website Content Editor</h2>
640              
641             <h3><a href="/?password=">Logout</a></h3>
642              
643             <ul>
644             [% FOREACH folder IN folders %]
645             <li>
646             <strong>[% folder.path %]/</strong> [ <a href="/?folder=[% folder.path | uri %]">create new file</a> ]
647             <ul>
648             [% FOREACH file IN folder.files %]
649             <li><a href="/?folder=[% folder.path | uri %]&amp;file=[% file | uri%]">[% file %]</a></li>
650             [% END %]
651             </ul>
652             </li>
653             [% END %]
654             [% IF files.size %]
655             <li><strong>/</strong>
656             <ul>
657             [% FOREACH file IN files %]
658             <li><a href="/?file=[% file | uri%]">[% file %]</a></li>
659             [% END %]
660             </ul>
661             </li>
662             [% END %]
663             </ul>
664              
665             </body>
666              
667             </html>
668              
669             EOF
670             }
671             elsif ( $template eq 'edit.tt' )
672             {
673             $string = <<EOF;
674             <html>
675             <head>
676             <style>
677             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
678             </style>
679             </head>
680              
681             <body>
682              
683             <h1><a href="/">Website Content Editor</a></h2>
684              
685             <h2>[% folder %]/[% file %]</h2>
686              
687             <form action="/" method="POST">
688             [% IF folder %]
689             <input type="hidden" name="folder" value="[% folder | html %]">
690             [% END %]
691             [% IF file %]
692             <input type="hidden" name="file" value="[% file | html %]">
693             [% ELSE %]
694             <label for="file">New filename</label>
695             <input type="text" id="file" name="file" value="" style="margin-bottom: 10px;">
696             [% END %]
697             <textarea name="content" style="float: left; width: 100%; height: 600px;">[% content | html %]</textarea>
698              
699             <input type="submit" value="Save Changes" style="float: left; clear: both;">
700             </form>
701              
702             </body>
703              
704             </html>
705              
706             EOF
707             }
708              
709             return \$string;
710             }
711              
712             =head1 TODO
713              
714             Allow absolute paths to any part of the filesystem?
715              
716             =head1 AUTHOR
717              
718             Rob Brown, C<< <rob at intelcompute.com> >>
719              
720             =head1 LICENSE AND COPYRIGHT
721              
722             Copyright 2015 Rob Brown.
723              
724             This program is free software; you can redistribute it and/or modify it
725             under the terms of either: the GNU General Public License as published
726             by the Free Software Foundation; or the Artistic License.
727              
728             See http://dev.perl.org/licenses/ for more information.
729              
730             =cut
731              
732             1;
733