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   18532 use 5.006;
  1         5  
  1         55  
4              
5 1     1   246 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.07
30              
31             =cut
32              
33             our $VERSION = '0.07';
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             if ( exists $config->{ files } && ref $config->{ files } ne 'ARRAY' )
202             {
203             _print_red( "[ FAIL ]\n" );
204             print "'files' attribute is not a list.\nExiting...\n";
205             exit;
206             }
207              
208             if ( exists $config->{ folders } && ref $config->{ folders } ne 'ARRAY' )
209             {
210             _print_red( "[ FAIL ]\n" );
211             print "'folders' attribute is not a list.\nExiting...\n";
212             exit;
213             }
214              
215             if ( exists $config->{ uploads } && ref $config->{ uploads } ne 'ARRAY' )
216             {
217             _print_red( "[ FAIL ]\n" );
218             print "'uploads' attribute is not a list.\nExiting...\n";
219             exit;
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             "files" : [
316             "app.json",
317             "site/style.css"
318             ],
319             "folders" : [
320             "templates/content/organic",
321             "templates/content/store"
322             ],
323             "uploads" : [
324             "site/images"
325             ],
326             "password" : "foo",
327             "salt" : "abc123"
328             }
329              
330             Three fields which drive the editor are C<files>, C<folders> and C<uploads>, each of which is an array of paths, all relative to the base directory of the editor script.
331              
332             Files in the C<files> list are editable, but the editor can not create new files in their respective containing directories.
333              
334             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.
335              
336             The editor can upload files into any of the C<uploads> folders.
337              
338             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).
339              
340             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.
341              
342             =cut
343              
344             # returns a code-ref for the FCGI handler/server.
345              
346             sub _handler
347             {
348             my $self = shift;
349              
350             return sub {
351              
352             ##############
353             # initialise #
354             ##############
355              
356             my $req = Plack::Request->new( shift );
357              
358             my $res = $req->new_response;
359              
360             my %stash = (
361             app => $self,
362             req => $req,
363             now => DateTime->now,
364             started => join( '.', gettimeofday ),
365             );
366              
367             my $log = Log::AutoDump->new( base_dir => $stash{ app }->_base . '/logs', filename => 'editor.log' );
368              
369             $log->debug("Started");
370              
371             my $path = $req->uri->path;
372              
373             $log->debug( "Requested path: " . $path );
374              
375             $log->debug( $req->parameters );
376              
377             $stash{ app }->_reload_config( log => $log );
378              
379             ###############################
380             # check for password required #
381             ###############################
382              
383             my $template = 'login.tt';
384              
385             if ( ! exists $stash{ app }->config->{ password } )
386             {
387             $log->debug( "No password set, so going straight to index.tt" );
388              
389             $template = 'index.tt';
390             }
391             else
392             {
393             if ( exists $req->parameters->{ password } )
394             {
395             if ( $req->parameters->{ password } eq $stash{ app }->config->{ password } )
396             {
397             $res->cookies->{ password } = sha1_hex( $stash{ app }->config->{ salt } . $stash{ app }->config->{ password } );
398              
399             $template = 'index.tt';
400             }
401             else
402             {
403             $res->cookies->{ password } = '';
404             }
405             }
406             elsif ( $req->cookies->{ password } )
407             {
408             $log->debug( "We have a cookie for a password" );
409              
410             if ( $req->cookies->{ password } eq sha1_hex( $stash{ app }->config->{ salt } . $stash{ app }->config->{ password } ) )
411             {
412             $log->debug( "Cookie matches sha1 hash" );
413              
414             $template = 'index.tt';
415             }
416             }
417             }
418              
419             if ( $template ne 'login.tt' )
420             {
421             my @files = ();
422             my @folders = ();
423             my @uploads = ();
424              
425             #########
426             # files #
427             #########
428              
429             if ( exists $stash{ app }->config->{ files } )
430             {
431             foreach my $file ( @{ $stash{ app }->config->{ files } } )
432             {
433             $file =~ s/^\///g; # remove leading slashes
434             $file =~ s/\/$//g; # remove trailing slashes
435              
436             $log->debug( "Processing file: " . $file );
437              
438             if ( -f $stash{ app }->_base . '/' . $file )
439             {
440             push @files, $file;
441             }
442             }
443             }
444              
445             ###########
446             # folders #
447             ###########
448              
449             if ( exists $stash{ app }->config->{ folders } )
450             {
451             foreach my $folder ( @{ $stash{ app }->config->{ folders } } )
452             {
453             $folder =~ s/^\///g; # remove leading slashes
454             $folder =~ s/\/$//g; # remove trailing slashes
455              
456             $log->debug( "Processing folder: " . $folder );
457              
458             my $path = $stash{ app }->_base . '/' . $folder;
459              
460             if ( -d $path )
461             {
462             my %folder = ( path => $folder, files => [ ] );
463              
464             opendir( my $dh, $path ) || $log->debug("Can't opendir $path: $!");
465              
466             push @{ $folder{ files } }, sort { $a cmp $b } grep { ! -d ( $stash{ app }->_base . '/' . $folder . '/' . $_ ) } grep { $_ !~ /^\./ } readdir( $dh );
467              
468             closedir( $dh );
469              
470             push @folders, \%folder;
471             }
472             }
473             }
474              
475             ###########
476             # uploads #
477             ###########
478              
479             if ( exists $stash{ app }->config->{ uploads } )
480             {
481             foreach my $upload ( @{ $stash{ app }->config->{ uploads } } )
482             {
483             $upload =~ s/^\///g; # remove leading slashes
484             $upload =~ s/\/$//g; # remove trailing slashes
485              
486             $log->debug( "Processing upload: " . $upload );
487              
488             my $path = $stash{ app }->_base . '/' . $upload;
489              
490             if ( -d $path )
491             {
492             my %upload = ( path => $upload, files => [ ] );
493              
494             opendir( my $dh, $path ) || $log->debug("Can't opendir $path: $!");
495              
496             push @{ $upload{ files } }, sort { $a cmp $b } grep { ! -d ( $stash{ app }->_base . '/' . $upload . '/' . $_ ) } grep { $_ !~ /^\./ } readdir( $dh );
497              
498             closedir( $dh );
499              
500             push @uploads, \%upload;
501             }
502             }
503             }
504              
505             ######################################
506             # if we've requested a file, edit it #
507             ######################################
508              
509             if ( exists $req->parameters->{ file } && ! exists $req->parameters->{ folder } && ! $req->parameters->{ upload } )
510             {
511             foreach my $file ( @files )
512             {
513             next if $file ne $req->parameters->{ file };
514              
515             $stash{ file } = $req->parameters->{ file };
516              
517             $template = 'edit.tt';
518              
519             if ( ! exists $req->parameters->{ content } )
520             {
521             $log->debug( "Reading content of " . $stash{ app }->_base . '/' . $file );
522              
523             $stash{ content } = read_file( $stash{ app }->_base . '/' . $file, { binmode => ':utf8' } );
524             }
525             else
526             {
527              
528             if ( $req->parameters->{ file } =~ /\.json/ )
529             {
530             $log->debug( "It's a JSON file" );
531              
532             #########################
533             # basic json validation #
534             #########################
535              
536             eval {
537             my $json = JSON->new;
538              
539             $json->relaxed( 1 );
540              
541             $json->decode( $req->parameters->{ content } );
542             };
543            
544             $log->debug( $@ ) if $@;
545              
546             $stash{ error } = $@ if $@;
547             }
548              
549             if ( ! exists $stash{ error } )
550             {
551             write_file( $stash{ app }->_base . '/' . $file, { binmode => ':utf8' }, $req->parameters->{ content } );
552              
553             $stash{ success } = "Saved OK";
554             }
555              
556             $stash{ file } = $req->parameters->{ file };
557              
558             $stash{ content } = $req->parameters->{ content };
559             }
560             }
561             }
562             elsif ( exists $req->parameters->{ folder } )
563             {
564             foreach my $folder ( @folders )
565             {
566             next if $folder->{ path } ne $req->parameters->{ folder };
567              
568             $stash{ folder } = $req->parameters->{ folder };
569              
570             $template = 'edit.tt';
571              
572             if ( $req->parameters->{ file } )
573             {
574             foreach my $file ( @{ $folder->{ files } } )
575             {
576             next if $file ne $req->parameters->{ file };
577              
578             $stash{ file } = $req->parameters->{ file };
579              
580             if ( ! exists $req->parameters->{ content } )
581             {
582             $log->debug( "Reading content of " . $stash{ app }->_base . '/' . $folder->{ path } . '/' . $file );
583              
584             $stash{ content } = read_file( $stash{ app }->_base . '/' . $folder->{ path } . '/' . $file, { binmode => ':utf8' } );
585             }
586             }
587             }
588              
589             if ( exists $req->parameters->{ content } )
590             {
591             $log->debug( "We've got some content" );
592              
593             if ( $req->parameters->{ file } =~ /\.json/ )
594             {
595             $log->debug( "It's a JSON file" );
596              
597             #########################
598             # basic json validation #
599             #########################
600              
601             eval {
602             my $json = JSON->new;
603              
604             $json->relaxed( 1 );
605              
606             $json->decode( $req->parameters->{ content } );
607             };
608            
609             $log->debug( $@ ) if $@;
610              
611             $stash{ error } = $@ if $@;
612             }
613              
614             if ( ! exists $stash{ error } )
615             {
616             write_file( $stash{ app }->_base . '/' . $folder->{ path } . '/' . $req->parameters->{ file }, { binmode => ':utf8' }, $req->parameters->{ content } );
617            
618             $stash{ success } = "Saved OK";
619             }
620              
621             $stash{ file } = $req->parameters->{ file };
622              
623             $stash{ content } = $req->parameters->{ content };
624             }
625             }
626             }
627             elsif ( exists $req->parameters->{ upload } )
628             {
629             foreach my $upload ( @uploads )
630             {
631             next if $upload->{ path } ne $req->parameters->{ upload };
632              
633             $stash{ upload } = $req->parameters->{ upload };
634              
635             if ( my $uploaded = $req->upload('file') )
636             {
637             $log->debug( "Moving file from " . $uploaded->path . " to " . $stash{ app }->_base . '/' . $upload->{ path } . '/' . $uploaded->filename );
638              
639             rename $uploaded->path, $stash{ app }->_base . '/' . $upload->{ path } . '/' . $uploaded->filename;
640              
641             $res->redirect( '/' );
642              
643             return $res->finalize;
644             }
645            
646             if ( exists $req->parameters->{ delete } )
647             {
648             $log->debug( "Attempting to delete " . $stash{ app }->_base . '/' . $upload->{ path } . '/' . $req->parameters->{ delete } );
649              
650             unlink $stash{ app }->_base . '/' . $upload->{ path } . '/' . $req->parameters->{ delete };
651              
652             $res->redirect( '/' );
653              
654             return $res->finalize;
655             }
656             }
657             }
658              
659             $stash{ files } = \@files;
660             $stash{ folders } = \@folders;
661             $stash{ uploads } = \@uploads;
662             }
663              
664             ##############################
665             # responding with a template #
666             ##############################
667              
668             $stash{ error } =~ s/ at \/.*$// if exists $stash{ error };
669              
670             $res->status( 200 );
671              
672             my $tt = Template->new( ENCODING => 'UTF-8' );
673              
674             $log->debug("Processing template: " . $template );
675              
676             my $body = '';
677              
678             $tt->process( $stash{ app }->_template_tt( $template ), \%stash, \$body ) or $log->debug( $tt->error );
679              
680             $res->content_type('text/html; charset=utf-8');
681              
682             $res->body( encode( "UTF-8", $body ) );
683              
684             #########
685             # stats #
686             #########
687              
688             $stash{ took } = join( '.', gettimeofday ) - $stash{ started };
689            
690             $log->debug( "Took " . sprintf("%.5f", $stash{ took } ) . " seconds");
691              
692             return $res->finalize;
693             }
694             }
695              
696             sub _reload_config
697             {
698             my ( $self, %args ) = @_;
699              
700             my $log = $args{ log };
701              
702             $log->debug( "Opening config file: " . $self->filename );
703              
704             my $string = read_file( $self->filename, { binmode => ':utf8' } );
705              
706             my $config = undef;
707              
708             eval {
709             $self->config( decode_json $string );
710             };
711              
712             $log->debug( $@ ) if $@;
713              
714             return $self;
715             }
716              
717             # returns a scalar-ref to feed into TT
718              
719             sub _template_tt
720             {
721             my ( $self, $template ) = @_;
722              
723             my $string = '';
724              
725             if ( $template eq 'login.tt' )
726             {
727             $string = <<EOF;
728             <html>
729             <head>
730             <style>
731             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
732             </style>
733             </head>
734              
735             <body>
736              
737             <h1>Website Content Editor</h2>
738              
739             <form action="/" method="POST">
740             <input type="text" name="password" value="" style="float: left; clear: both;">
741              
742             <input type="submit" value="Login" style="float: left; clear: both;">
743             </form>
744              
745             </body>
746              
747             </html>
748              
749             EOF
750             }
751             elsif ( $template eq 'index.tt' )
752             {
753             $string = <<EOF;
754             <html>
755             <head>
756             <style>
757             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
758             ul li { margin-top: 5px; }
759             a,a:visited { color: #0000EE; }
760             </style>
761             </head>
762              
763             <body>
764              
765             <h1>Website Content Editor</h2>
766              
767             <h3><a href="/?password=">Logout</a></h3>
768              
769             <ul>
770             [% FOREACH folder IN folders %]
771             <li>
772             <strong>[% folder.path %]/</strong> [ <a href="/?folder=[% folder.path | uri %]">create new file</a> ]
773             <ul>
774             [% FOREACH file IN folder.files %]
775             <li><a href="/?folder=[% folder.path | uri %]&amp;file=[% file | uri%]">[% file %]</a></li>
776             [% END %]
777             </ul>
778             </li>
779             [% END %]
780             [% IF files.size %]
781             <li><strong>/</strong>
782             <ul>
783             [% FOREACH file IN files %]
784             <li><a href="/?file=[% file | uri%]">[% file %]</a></li>
785             [% END %]
786             </ul>
787             </li>
788             [% END %]
789             </ul>
790              
791             [% IF uploads.size %]
792             <h3>Uploads</h3>
793             <ul>
794             [% FOREACH upload IN uploads %]
795             <li>
796             <strong>[% upload.path | html %]/</strong>
797             <form action="/" enctype="multipart/form-data" method="post">
798             <input type="hidden" name="upload" value="[% upload.path | html %]">
799             <input type="file" name="file">
800             <input type="submit" value="Upload">
801             </form>
802             <ul>
803             [% FOREACH file IN upload.files %]
804             <li>[% file %] [ <a href="/?upload=[% upload.path | html %]&amp;delete=[% file | html %]">delete</a> ]</li>
805             [% END %]
806             </ul>
807             </li>
808             [% END %]
809             </ul>
810             [% END %]
811             </body>
812              
813             </html>
814              
815             EOF
816             }
817             elsif ( $template eq 'edit.tt' )
818             {
819             $string = <<EOF;
820             <html>
821             <head>
822             <style>
823             body { font-family: Tahoma,Arial,Helvetica,sans-serif; }
824             a,a:visited { color: #0000EE; }
825             </style>
826             </head>
827              
828             <body>
829              
830             <h1><a href="/">Website Content Editor</a></h2>
831              
832             <h2>[% folder %]/[% file %][% IF success %] - <span style="color: #0c0;">[% success %]</span>[% END %][% IF error %] - <span style="color: #f00;">[% error %]</span>[% END %]</h2>
833              
834             <form action="/" method="POST">
835             [% IF folder %]
836             <input type="hidden" name="folder" value="[% folder | html %]">
837             [% END %]
838             [% IF file %]
839             <input type="hidden" name="file" value="[% file | html %]">
840             [% ELSE %]
841             <label for="file">New filename</label>
842             <input type="text" id="file" name="file" value="" style="margin-bottom: 10px;">
843             [% END %]
844             <textarea name="content" style="float: left; width: 100%; height: 600px;">[% content | html %]</textarea>
845              
846             <input type="submit" value="Save Changes" style="float: left; clear: both;">
847             </form>
848              
849             </body>
850              
851             </html>
852              
853             EOF
854             }
855              
856             return \$string;
857             }
858              
859             =head1 TODO
860              
861             Allow absolute paths to any part of the filesystem?
862              
863             =head1 AUTHOR
864              
865             Rob Brown, C<< <rob at intelcompute.com> >>
866              
867             =head1 LICENSE AND COPYRIGHT
868              
869             Copyright 2015 Rob Brown.
870              
871             This program is free software; you can redistribute it and/or modify it
872             under the terms of either: the GNU General Public License as published
873             by the Free Software Foundation; or the Artistic License.
874              
875             See http://dev.perl.org/licenses/ for more information.
876              
877             =cut
878              
879             1;
880