File Coverage

blib/lib/Rapi/Blog.pm
Criterion Covered Total %
statement 36 83 43.3
branch 0 38 0.0
condition 0 13 0.0
subroutine 12 23 52.1
pod 0 1 0.0
total 48 158 30.3


line stmt bran cond sub pod time code
1             package Rapi::Blog;
2              
3 1     1   623 use strict;
  1         2  
  1         22  
4 1     1   4 use warnings;
  1         1  
  1         23  
5              
6             # ABSTRACT: RapidApp-powered blog
7              
8 1     1   414 use RapidApp 1.3200;
  1         22590  
  1         24  
9              
10 1     1   454 use Moose;
  1         410418  
  1         7  
11             extends 'RapidApp::Builder';
12              
13 1     1   6695 use Types::Standard qw(:all);
  1         57162  
  1         10  
14              
15 1     1   40487 use RapidApp::Util ':all';
  1         1056900  
  1         478  
16 1     1   8 use File::ShareDir qw(dist_dir);
  1         2  
  1         53  
17 1     1   546 use FindBin;
  1         842  
  1         45  
18             require Module::Locate;
19 1     1   6 use Path::Class qw/file dir/;
  1         2  
  1         46  
20 1     1   371 use YAML::XS 0.64 'LoadFile';
  1         2358  
  1         46  
21              
22 1     1   443 use Rapi::Blog::Scaffold;
  1         3  
  1         33  
23 1     1   363 use Rapi::Blog::Scaffold::Set;
  1         4  
  1         2013  
24              
25             our $VERSION = '1.1301';
26             our $TITLE = "Rapi::Blog v" . $VERSION;
27              
28             has 'site_path', is => 'ro', required => 1;
29             has 'scaffold_path', is => 'ro', isa => Maybe[Str], default => sub { undef };
30             has 'builtin_scaffold', is => 'ro', isa => Maybe[Str], default => sub { undef };
31             has 'scaffold_config', is => 'ro', isa => HashRef, default => sub {{}};
32             has 'fallback_builtin_scaffold', is => 'ro', isa => Bool, default => sub {0};
33              
34             has 'enable_password_reset', is => 'ro', isa => Bool, default => sub {1};
35             has 'enable_user_sign_up', is => 'ro', isa => Bool, default => sub {1};
36             has 'enable_email_login', is => 'ro', isa => Bool, default => sub {1};
37              
38             has 'underlay_scaffolds', is => 'ro', isa => ArrayRef[Str], default => sub {[]};
39              
40             has 'smtp_config', is => 'ro', isa => Maybe[HashRef], default => sub { undef };
41             has 'override_email_recipient', is => 'ro', isa => Maybe[Str], default => sub { undef };
42              
43             has 'recaptcha_config', is => 'ro', isa => Maybe[HashRef[Str]], default => sub { undef };
44              
45              
46             has '+base_appname', default => sub { 'Rapi::Blog::App' };
47             has '+debug', default => sub {1};
48              
49             sub BUILD {
50 0     0 0   my $self = shift;
51 0 0         print STDERR join('',' -- ',(blessed $self),' v',$self->VERSION,' -- ',"\n") if ($self->debug);
52             }
53              
54             has 'share_dir', is => 'ro', isa => Str, lazy => 1, default => sub {
55             my $self = shift;
56             $self->_get_share_dir;
57             };
58              
59             sub _get_share_dir {
60 0   0 0     my $self = shift || __PACKAGE__;
61             $ENV{RAPI_BLOG_SHARE_DIR} || (
62 0     0     try{dist_dir('Rapi-Blog')} || (
63 0 0 0       -d "$FindBin::Bin/share" ? "$FindBin::Bin/share" :
    0          
    0          
64             -d "$FindBin::Bin/../share" ? "$FindBin::Bin/../share" :
65             join('',$self->_module_locate_dir,'/../../share')
66             )
67             )
68             }
69              
70             sub _module_locate_dir {
71 0     0     my $self = shift;
72 0 0         my $pm_path = Module::Locate::locate('Rapi::Blog') or die "Failed to locate Rapi::Blog?!";
73 0           file($pm_path)->parent->stringify
74             }
75              
76             has '+inject_components', default => sub {
77             my $self = shift;
78             my $model = 'Rapi::Blog::Model::DB';
79            
80             my $db = $self->site_dir->file('rapi_blog.db');
81            
82             Module::Runtime::require_module($model);
83             $model->config->{connect_info}{dsn} = "dbi:SQLite:$db";
84              
85             return [
86             [ $model => 'Model::DB' ],
87             [ 'Rapi::Blog::Model::Mailer' => 'Model::Mailer' ],
88             [ 'Rapi::Blog::Controller::Remote' => 'Controller::Remote' ],
89             [ 'Rapi::Blog::Controller::Remote::PreauthAction' => 'Controller::Remote::PreauthAction' ]
90             ]
91             };
92              
93              
94              
95             has 'site_dir', is => 'ro', init_arg => undef, lazy => 1, default => sub {
96             my $self = shift;
97            
98             my $Dir = dir( $self->site_path )->absolute;
99             -d $Dir or die "Scaffold directory '$Dir' not found.\n";
100            
101             return $Dir
102             }, isa => InstanceOf['Path::Class::Dir'];
103              
104              
105             has 'scaffolds', is => 'ro', lazy => 1, default => sub { undef };
106              
107             has 'ScaffoldSet', is => 'ro', init_arg => undef, lazy => 1, default => sub {
108             my $self = shift;
109            
110             my $scafs = $self->scaffolds || [];
111             $scafs = [ $scafs ] unless (ref($scafs)||'' eq 'ARRAY');
112             $scafs = [ $self->scaffold_dir ] unless (scalar(@$scafs) > 0);
113            
114             my @list = map {
115             Rapi::Blog::Scaffold->factory( $_ )
116             } @$scafs, @{ $self->_get_underlay_scaffold_dirs };
117            
118             my $Set = Rapi::Blog::Scaffold::Set->new( Scaffolds => \@list );
119            
120             # Apply any custom configs to the *first* scaffold:
121             $self->scaffold_config and $Set->first->config->_apply_params( $self->scaffold_config );
122            
123             $Set
124              
125             }, isa => InstanceOf['Rapi::Blog::Scaffold::Set'];
126              
127              
128             # This exists to be able to provide access to the running Blog config, including within
129             # templates, without the risk associated with providing direct access to the Rapi::Blog
130             # instance outright:
131             has 'BlogCfg', is => 'ro', init_arg => undef, lazy => 1, default => sub {
132             my $self = shift;
133            
134             my @keys = grep {
135             my $Attr = $self->meta->get_attribute($_);
136            
137             !($_ =~ /^_/) # ignore attrs with private names (i.e. start with "_")
138             && ($Attr->reader||'') eq $_ # only consider attributes with normal accessor names
139             && $Attr->has_value($self) # and already have a value
140              
141             } $self->meta->get_attribute_list;
142              
143             # If any normal methods are desired in the future, add them to keys here
144            
145             my $cfg = { map { $_ => $self->$_ } @keys };
146              
147             $cfg
148             }, isa => HashRef;
149              
150              
151              
152             # Single merged config object which considers, prioritizes and flattens the configs of all scaffolds
153             has 'scaffold_cfg', is => 'ro', init_arg => undef, lazy => 1, default => sub {
154             my $self = shift;
155            
156             my %merged = (
157             map {
158             %{ $_->config->_all_as_hash }
159             } reverse ($self->ScaffoldSet->all)
160             );
161            
162             Rapi::Blog::Scaffold::Config->new( %merged )
163              
164             }, isa => InstanceOf['Rapi::Blog::Scaffold::Config'];
165              
166              
167              
168             has 'scaffold_dir', is => 'ro', init_arg => undef, lazy => 1, default => sub {
169             my $self = shift;
170            
171             my $path;
172            
173             if(my $scaffold_name = $self->builtin_scaffold) {
174             die join('',
175             " Error: don't use both 'builtin_scaffold' and 'scaffold_path' options"
176             ) if ($self->scaffold_path);
177             my $Dir = $self->_get_builtin_scaffold_dir($scaffold_name)->absolute;
178             -d $Dir or die "builtin scaffold '$scaffold_name' not found\n";
179            
180             $path = $Dir->stringify;
181             }
182             else {
183             $path = $self->scaffold_path || $self->site_dir->subdir('scaffold');
184             }
185            
186             my $Dir = dir( $path );
187             if(! -d $Dir) {
188             if($self->fallback_builtin_scaffold) {
189             my $scaffold_name = 'bootstrap-blog';
190             warn join('',
191             "\n ** WARNING: local scaffold directory not found;\n --> using builtin ",
192             "scaffold '$scaffold_name' (fallback_builtin_scaffold is set to true)\n\n"
193             );
194             $Dir = $self->_get_builtin_scaffold_dir($scaffold_name);
195             -d $Dir or die join('',
196             " Fatal error: fallback scaffold not found (this could indicate a ",
197             "problem with your Rapi::Blog installation)\n\n"
198             );
199             }
200             else {
201             die "Scaffold directory '$Dir' not found.\n";
202             }
203             }
204             return $Dir
205             }, isa => InstanceOf['Path::Class::Dir'];
206              
207             sub _get_builtin_scaffold_dir {
208 0     0     my ($self, $scaffold_name) = @_;
209 0   0       $scaffold_name ||= 'bootstrap-blog';
210            
211 0           my $Scaffolds = dir( $self->share_dir )->subdir('scaffolds')->absolute;
212 0 0         -d $Scaffolds or die join('',
213             " Fatal error: Unable to locate scaffold share dir (this could indicate a ",
214             "problem with your Rapi::Blog installation)\n\n"
215             );
216            
217 0           $Scaffolds->subdir($scaffold_name)
218             }
219              
220              
221             after 'bootstrap' => sub {
222             my $self = shift;
223            
224             my $c = $self->appname;
225             $c->setup_plugins(['+Rapi::Blog::CatalystApp']);
226            
227             };
228              
229              
230             sub _get_underlay_scaffold_dirs {
231 0     0     my $self = shift;
232              
233 0           my $CommonUnderlay = dir( $self->share_dir )->subdir('common_underlay')->absolute;
234 0 0         -d $CommonUnderlay or die join('',
235             " Fatal error: Unable to locate common underlay scaffold dir (this could ",
236             "indicate a problem with your Rapi::Blog installation)\n\n"
237             );
238            
239             return [
240 0           @{$self->underlay_scaffolds},
  0            
241             $CommonUnderlay
242             ]
243             }
244              
245              
246             sub _enforce_valid_recaptcha_config {
247 0     0     my $self = shift;
248 0 0         my $cfg = $self->recaptcha_config or return 1; # No config at all is valid
249            
250 0           my @valid_keys = qw/public_key private_key verify_url strict_mode/;
251 0           my %keys = map {$_=>1} @valid_keys;
  0            
252 0           for my $k (keys %$cfg) {
253 0 0         $keys{$k} or die join('',
254             "Unknown recaptcha_config param '$k' - ",
255             "only valid params are: ",join(', ',@valid_keys)
256             )
257             }
258            
259             die "Invalid recaptcha_config - both 'public_key' and 'private_key' params are required"
260 0 0 0       unless ($cfg->{public_key} && $cfg->{private_key});
261            
262 0 0         if(exists $cfg->{strict_mode}) {
263 0           my $v = $cfg->{strict_mode};
264 0 0         my $disp = defined $v ? "'$v'" : 'undef';
265 0 0 0       die "Bad value $disp for 'strict_mode' in recaptcha_config - must be either 1 (true) or 0 (false)\n"
266             unless ("$v" eq '0' || "$v" eq '1')
267             }
268             }
269              
270              
271 0     0     sub _build_version { $VERSION }
272 0     0     sub _build_plugins { [qw/
273             RapidApp::RapidDbic
274             RapidApp::AuthCore
275             RapidApp::NavCore
276             RapidApp::CoreSchemaAdmin
277             /]}
278              
279             sub _build_base_config {
280 0     0     my $self = shift;
281            
282 0           $self->_enforce_valid_recaptcha_config;
283            
284 0           my $tpl_dir = join('/',$self->share_dir,'templates');
285 0 0         -d $tpl_dir or die join('',
286             "template dir ($tpl_dir) not found; ",
287             __PACKAGE__, " may not be installed properly.\n"
288             );
289            
290 0           my $loc_assets_dir = join('/',$self->share_dir,'assets');
291 0 0         -d $loc_assets_dir or die join('',
292             "assets dir ($loc_assets_dir) not found; ",
293             __PACKAGE__, " may not be installed properly.\n"
294             );
295            
296             my $config = {
297            
298             'RapidApp' => {
299             module_root_namespace => 'adm',
300             local_assets_dir => $loc_assets_dir,
301            
302             load_modules => {
303             sections => {
304             class => 'Rapi::Blog::Module::SectionTree',
305             params => {}
306             }
307             },
308            
309             },
310            
311             'Plugin::RapidApp::NavCore' => {
312             navtree_class => 'Rapi::Blog::Module::NavTree',
313             },
314            
315             'Model::RapidApp::CoreSchema' => {
316             sqlite_file => $self->site_dir->file('rapidapp_coreschema.db')->stringify
317             },
318            
319             'Plugin::RapidApp::AuthCore' => {
320             linked_user_model => 'DB::User'
321             },
322            
323             'Controller::SimpleCAS' => {
324             store_path => $self->site_dir->subdir('cas_store')->stringify
325             },
326            
327             'Plugin::RapidApp::TabGui' => {
328             title => $TITLE,
329             nav_title => 'Administration',
330             banner_template => file($tpl_dir,'banner.html')->stringify,
331             dashboard_url => '/tpl/dashboard.md',
332             navtree_init_width => 190,
333             },
334            
335             'Controller::RapidApp::Template' => {
336             root_template_prefix => '/',
337             root_template => $self->scaffold_cfg->landing_page,
338             read_alias_path => '/tpl', #<-- already the default
339             edit_alias_path => '/tple', #<-- already the default
340             default_template_extension => undef,
341             include_paths => [ $tpl_dir ],
342             access_class => 'Rapi::Blog::Template::AccessStore',
343             access_params => {
344            
345             BlogCfg => $self->BlogCfg,
346             ScaffoldSet => $self->ScaffoldSet,
347             scaffold_cfg => $self->scaffold_cfg,
348            
349             #internal_post_path => $self->scaffold_cfg->internal_post_path,
350             #default_view_path => $self->scaffold_cfg->default_view_path,
351            
352            
353             #scaffold_dir => $self->scaffold_dir,
354             #scaffold_cnf => $self->scaffold_cnf,
355             #static_paths => $self->scaffold_cnf->{static_paths},
356             #private_paths => $self->scaffold_cnf->{private_paths},
357             #default_ext => $self->scaffold_cnf->{default_ext},
358             #
359             #internal_post_path => $self->scaffold_cnf->{internal_post_path},
360             #view_wrappers => $self->scaffold_cnf->{view_wrappers},
361             #default_view_path => $self->default_view_path,
362             #preview_path => $self->preview_path,
363             #
364             #underlay_scaffold_dirs => $self->_get_underlay_scaffold_dirs,
365              
366 0     0     get_Model => sub { $self->base_appname->model('DB') }
367             }
368             },
369            
370 0 0         'Model::Mailer' => {
371             smtp_config => $self->smtp_config,
372             ( $self->override_email_recipient ? (envelope_to => $self->override_email_recipient) : () )
373             }
374            
375             };
376            
377 0 0         if(my $faviconPath = $self->ScaffoldSet->first_config_value_filepath('favicon')) {
378 0           $config->{RapidApp}{default_favicon_url} = $faviconPath;
379             }
380            
381 0 0         if(my $loginTpl = $self->ScaffoldSet->first_config_value_file('login')) {
382 0           $config->{'Plugin::RapidApp::AuthCore'}{login_template} = $loginTpl;
383             }
384            
385 0 0         if(my $errorTpl = $self->ScaffoldSet->first_config_value_file('error')) {
386 0           $config->{'RapidApp'}{error_template} = $errorTpl;
387             }
388            
389 0           return $config
390             }
391              
392             1;
393              
394             __END__
395              
396             =head1 NAME
397              
398             Rapi::Blog - Plack-compatible, RapidApp-based blog engine
399              
400             =head1 SYNOPSIS
401              
402             use Rapi::Blog;
403            
404             my $app = Rapi::Blog->new({
405             site_path => '/path/to/some-site',
406             scaffold_path => '/path/to/some-site/scaffold', # default
407             });
408              
409             # Plack/PSGI app:
410             $app->to_app
411              
412             Create a new site from scratch using the L<rabl.pl> utility script:
413              
414             rabl.pl create /path/to/some-site
415             cd /path/to/some-site && plackup
416              
417             =head1 DESCRIPTION
418              
419             This is a L<Plack>-compatible blogging platform written using L<RapidApp>. This module was first
420             released during The Perl Conference 2017 in Washington D.C. where a talk/demo was given on the
421             platform:
422              
423             =begin HTML
424              
425             <p><a href="http://rapi.io/tpc2017"><img
426             src="https://raw.githubusercontent.com/vanstyn/Rapi-Blog/master/share/tpc2017-video-preview.png"
427             width="800"
428             alt="Rapi::Blog talk/video"
429             title="Rapi::Blog talk/video"
430             /></a></p>
431              
432             =end HTML
433              
434             L<rapi.io/tpc2017|http://rapi.io/tpc2017>
435              
436             See L<Rapi::Blog::Manual> for more information and usage.
437              
438             =head1 CONFIGURATION
439              
440             C<Rapi::Blog> extends L<RapidApp::Builder> and supports all of its options, as well as the following
441             params specific to this module:
442              
443             =head2 site_path
444              
445             Only required param - path to the directory containing the site.
446              
447             =head2 scaffold_path
448              
449             Path to the directory containing the "scaffold" of the site. This is like a document root with
450             some extra functionality.
451              
452             If not supplied, defaults to C<'scaffold/'> within the C<site_path> directory.
453              
454             =head2 builtin_scaffold
455              
456             Alternative to C<scaffold_path>, the name of one of the builtin skeleton scaffolds to use as the
457             live scaffold. This is mainly useful for dev and content-only testing. As of version C<1.0000>) there
458             are two built-in scaffolds:
459              
460             =head3 bootstrap-blog
461              
462             This is the default out-of-the-box scaffold which is based on the "Blog" example from the Twitter
463             Bootstrap HTML/CSS framework (v3.3.7): L<http://getbootstrap.com/examples/blog/>. This mainly exists
464             to serve as a useful reference implementation of the basic features/directives provided by the
465             Template API.
466              
467             =head3 keep-it-simple
468              
469             Based on the "Keep It Simple" website template by L<http://www.Styleshout.com>
470              
471             =head2 fallback_builtin_scaffold
472              
473             If set to true and the local scaffold directory doesn't exist, the default builtin skeleton scaffold
474             'bootstrap-blog' will be used instead. Useful for testing and content-only scenarios.
475              
476             Defaults to false.
477              
478             =head2 smtp_config
479              
480             Optional HashRef of L<Email::Sender::Transport::SMTP> params which will be used by the app for
481             sending E-Mails, such as password resets and other notifications. The options are passed directly
482             to C<Email::Sender::Transport::SMTP->new()>. If the special param C<transport_class> is included,
483             it will be used as the transport class instead of C<Email::Sender::Transport::SMTP>. If this is
484             supplied, it should still be a valid L<Email::Sender::Transport> class.
485              
486             If this option is not supplied, E-Mails will be sent via the localhost using C<sendmail> via
487             the default L<Email::Sender::Transport::Sendmail> options.
488              
489             =head2 override_email_recipient
490              
491             If set, all e-mails generated by the system will be sent to the specified address instead of normal
492             recipients.
493              
494             =head2 recaptcha_config
495              
496             Optional HashRef config to enable Google reCAPTCHA v2 validation on supported forms. An account and API
497             key pair must be setup with Google first. This config supports the following params:
498              
499             =head3 public_key
500              
501             Required. The public, or "SITE KEY" provided by the Google reCAPTCHA settings, after being setup in the
502             Google reCAPTCHA system L<www.google.com/recaptcha/admin|http://www.google.com/recaptcha/admin>
503              
504             =head3 private_key
505              
506             Required. The private, or "SECRET KEY" provided by the Google reCAPTCHA settings, after being setup in the
507             Google reCAPTCHA system L<www.google.com/recaptcha/admin|http://www.google.com/recaptcha/admin>. Both the
508             C<public_key> and the C<private_key> are provided as a pair and both are required.
509              
510             =head3 verify_url
511              
512             Optional URL to use when performing the actual reCAPCTHA validation with Google. Defaults to
513             C<https://www.google.com/recaptcha/api/siteverify> which should probably never need to be changed.
514              
515             =head3 strict_mode
516              
517             Optional mode (turned off by default) which can be enabled to tighten the enforcement reCAPTCHA,
518             requiring it in all locations which is is setup on the server side, regardless of whether or not the
519             client form is actually prompting the user with the appropriate reCAPTCHA "I am not a robot" checkbox
520             dialog. In those cases of client-side forms not properly setup, they will never be able to submit
521             because they will always fail reCAPTCHA validation.
522              
523             When this mode is off (the default) reCAPTCHA validation is only performed when both the client and
524             the server are properly setup. The downside of this is that it leaves open the scenario of a spammer
525             direct posting to the server instead of using the actual form. Whether or not this mode should be
526             used should be based on need -- if spammers exploit this, turn it on. Otherwise, it is best to leave
527             it off as it turning it on has the potential to make the site less resilient and reliable.
528              
529             This param accepts only 2 possible values: 1 (enabled) or 0 (disabled, which is the default).
530              
531              
532             =head1 METHODS
533              
534             =head2 to_app
535              
536             PSGI C<$app> CodeRef. Derives from L<Plack::Component>
537              
538             =head1 SEE ALSO
539              
540             =over
541              
542             =item *
543              
544             L<rabl.pl>
545              
546             =item *
547              
548             L<Rapi::Blog::Manual>
549              
550             =item *
551              
552             L<RapidApp>
553              
554             =item *
555              
556             L<RapidApp::Builder>
557              
558             =item *
559              
560             L<Plack>
561              
562             =item *
563              
564             L<http://rapi.io/blog>
565              
566             =back
567              
568              
569             =head1 AUTHOR
570              
571             Henry Van Styn <vanstyn@cpan.org>
572              
573             =head1 COPYRIGHT AND LICENSE
574              
575             This software is copyright (c) 2017 by IntelliTree Solutions llc.
576              
577             This is free software; you can redistribute it and/or modify it under
578             the same terms as the Perl 5 programming language system itself.
579              
580             =cut
581              
582