1package MojoMojo;
2
3use strict;
4use Path::Class 'file';
5
6use Catalyst qw/
7  ConfigLoader
8  Authentication
9  Cache
10  Session
11  Session::Store::Cache
12  Session::State::Cookie
13  Static::Simple
14  SubRequest
15  I18N
16  Setenv
17  /;
18
19use Storable;
20use Digest::MD5;
21use Data::Dumper;
22use DateTime;
23use MRO::Compat;
24use DBIx::Class::ResultClass::HashRefInflator;
25use Encode      ();
26use URI::Escape ();
27use MojoMojo::Formatter::Wiki;
28use Module::Pluggable::Ordered
29  search_path => 'MojoMojo::Formatter',
30  except      => qr/^MojoMojo::Plugin::/,
31  require     => 1;
32
33our $VERSION = '1.12';
34use 5.008004;
35
36MojoMojo->config->{authentication}{dbic} = {
37  user_class     => 'DBIC::Person',
38  user_field     => 'login',
39  password_field => 'pass'
40};
41MojoMojo->config->{default_view} = 'TT';
42MojoMojo->config->{'Plugin::Cache'}{backend} = {
43  class          => "Cache::FastMmap",
44  unlink_on_exit => 1,
45  share_file     => ''
46    . Path::Class::file(
47    File::Spec->tmpdir,
48    'mojomojo-sharefile-' . Digest::MD5::md5_hex(MojoMojo->config->{home})
49    ),
50};
51
52__PACKAGE__->config(
53  authentication => {
54    default_realm => 'members',
55    use_session   => 1,
56    realms        => {
57      members => {
58        credential => {
59          class              => 'Password',
60          password_field     => 'pass',
61          password_type      => 'hashed',
62          password_hash_type => 'SHA-1',
63        },
64        store => {class => 'DBIx::Class', user_class => 'DBIC::Person',},
65      },
66    }
67  }
68);
69
70__PACKAGE__->config('Controller::HTML::FormFu' =>
71    {languages_from_context => 1, localize_from_context => 1,});
72
73__PACKAGE__->config(setup_components => {search_extra => ['::Extensions'],});
74
75MojoMojo->setup();
76
77# Check for deployed database
78my $has_DB        = 1;
79my $NO_DB_MESSAGE = <<"EOF";
80
81    ***********************************************
82    ERROR. Looks like you need to deploy a database.
83    Run script/mojomojo_spawn_db.pl
84    ***********************************************
85
86EOF
87eval {
88  MojoMojo->model('DBIC')
89    ->schema->resultset('MojoMojo::Schema::Result::Person')->next;
90};
91if ($@) {
92  $has_DB = 0;
93  warn $NO_DB_MESSAGE;
94  warn "(Error: $@)";
95}
96
97MojoMojo->model('DBIC')
98  ->schema->attachment_dir(MojoMojo->config->{attachment_dir}
99    || MojoMojo->path_to('uploads') . '');
100
101=head1 NAME
102
103MojoMojo - A Wiki with a tree
104
105=head1 SYNOPSIS
106
107  # Set up database (see mojomojo.conf first)
108
109  ./script/mojomojo_spawn_db.pl
110
111  # Standalone mode
112
113  ./script/mojomo_server.pl
114
115  # In apache conf
116  <Location /mojomojo>
117    SetHandler perl-script
118    PerlHandler MojoMojo
119  </Location>
120
121=head1 DESCRIPTION
122
123Mojomojo is a content management system, borrowing many concepts from
124wikis and blogs. It allows you to maintain a full tree-structure of pages,
125and to interlink them in various ways. It has full version support, so you can
126always go back to a previous version and see what's changed with an easy diff
127system. There are also a some of useful features like live AJAX preview while
128editing, tagging, built-in fulltext search, image galleries, and RSS feeds
129for every wiki page.
130
131To find out more about how you can use MojoMojo, please visit
132L<http://mojomojo.org/> or read the installation instructions in
133L<MojoMojo::Installation> to try it out yourself.
134
135=head1 METHODS
136
137=head2 prepare
138
139Accommodate a forcing of SSL if needed in a reverse proxy setup.
140
141=cut
142
143sub prepare {
144  my $self = shift->next::method(@_);
145  if ($self->config->{force_ssl}) {
146    my $request = $self->request;
147    $request->base->scheme('https');
148    $request->uri->scheme('https');
149  }
150  return $self;
151}
152
153
154=head2 ajax
155
156Return whether the request is an AJAX one (used by the live preview,
157for example), as opposed to a rgular request (such as one used to view
158a page).
159
160=cut
161
162sub ajax {
163  my ($c) = @_;
164  return $c->req->header('x-requested-with')
165    && $c->req->header('x-requested-with') eq 'XMLHttpRequest';
166}
167
168=head2 expand_wikilink
169
170Proxy method for the L<MojoMojo::Formatter::Wiki> expand_wikilink method.
171
172=cut
173
174sub expand_wikilink {
175  my $c = shift;
176  return MojoMojo::Formatter::Wiki->expand_wikilink(@_);
177}
178
179=head2 wikiword
180
181Format a wikiword as a link or as a wanted page, as appropriate.
182
183=cut
184
185sub wikiword {
186  return MojoMojo::Formatter::Wiki->format_link(@_);
187}
188
189=head2 pref
190
191Find or create a preference key. Update it if a value is passed, then
192return the current setting.
193
194=cut
195
196sub pref {
197  my ($c, $setting, $value) = @_;
198
199  return unless $setting;
200
201  # Unfortunately there are MojoMojo->pref() calls in
202  # MojoMojo::Schema::Result::Person which makes it hard
203  # to get cache working for those calls - so we'll just
204  # not use caching for those calls.
205  return $c->pref_cached($setting, $value) if ref($c) eq 'MojoMojo';
206
207  $setting
208    = $c->model('DBIC::Preference')->find_or_create({prefkey => $setting});
209  if (defined $value) {
210    $setting->prefvalue($value);
211    $setting->update();
212    return $value;
213  }
214  return (defined $setting->prefvalue() ? $setting->prefvalue : "");
215}
216
217=head2 pref_cached
218
219Get preference key/value from cache if possible.
220
221=cut
222
223sub pref_cached {
224  my ($c, $setting, $value) = @_;
225
226  # Already in cache and no new value to set?
227  if (defined $c->cache->get($setting) and not defined $value) {
228    return $c->cache->get($setting);
229  }
230
231  # Check that we have a database, i.e. script/mojomojo_spawn_db.pl was run.
232  my $row;
233  $row = $c->model('DBIC::Preference')->find_or_create({prefkey => $setting});
234
235  # Update database
236  $row->update({prefvalue => $value}) if defined $value;
237
238  my $prefvalue = $row->prefvalue();
239
240  # if no entry in preferences, try get one from config or get default value
241  unless (defined $prefvalue) {
242
243    if ($setting eq 'main_formatter') {
244      $prefvalue
245        = defined $c->config->{'main_formatter'}
246        ? $c->config->{'main_formatter'}
247        : 'MojoMojo::Formatter::Markdown';
248    }
249    elsif ($setting eq 'default_lang') {
250      $prefvalue
251        = defined $c->config->{$setting} ? $c->config->{$setting} : 'en';
252    }
253    elsif ($setting eq 'name') {
254      $prefvalue
255        = defined $c->config->{$setting} ? $c->config->{$setting} : 'MojoMojo';
256    }
257    elsif ($setting eq 'theme') {
258      $prefvalue
259        = defined $c->config->{$setting} ? $c->config->{$setting} : 'default';
260    }
261    elsif ($setting =~ /^(enforce_login|check_permission_on_view)$/) {
262      $prefvalue
263        = defined $c->config->{'permissions'}{$setting}
264        ? $c->config->{'permissions'}{$setting}
265        : 0;
266    }
267    elsif ($setting
268      =~ /^(cache_permission_data|create_allowed|delete_allowed|edit_allowed|view_allowed|attachment_allowed)$/
269      )
270    {
271      $prefvalue
272        = defined $c->config->{'permissions'}{$setting}
273        ? $c->config->{'permissions'}{$setting}
274        : 1;
275    }
276    else {
277      $prefvalue = $c->config->{$setting};
278    }
279
280  }
281
282  # Update cache
283  $c->cache->set($setting => $prefvalue);
284
285  return $c->cache->get($setting);
286}
287
288=head2 fixw
289
290Clean up wiki words: replace spaces with underscores and remove non-\w, / and .
291characters.
292
293=cut
294
295sub fixw {
296  my ($c, $w) = @_;
297  $w =~ s/\s/\_/g;
298  $w =~ s/[^\w\/\.]//g;
299  return $w;
300}
301
302=head2 tz
303
304Convert timezone
305
306=cut
307
308sub tz {
309  my ($c, $dt) = @_;
310  if ($c->user && $c->user->timezone) {
311    eval { $dt->set_time_zone($c->user->timezone) };
312  }
313  return $dt;
314}
315
316=head2 prepare_action
317
318Provide "No DB" message when one needs to spawn the db (script/mojomojo_spawn.pl).
319
320=cut
321
322sub prepare_action {
323  my $c = shift;
324
325  if ($has_DB) {
326    $c->next::method(@_);
327  }
328  else {
329    $c->res->status(404);
330    $c->response->body($NO_DB_MESSAGE);
331    return;
332  }
333}
334
335=head2 prepare_path
336
337We override this method to work around some of Catalyst's assumptions about
338dispatching. Since MojoMojo supports page namespaces
339(e.g. C</parent_page/child_page>), with page paths that always start with C</>,
340we strip the trailing slash from C<< $c->req->base >>. Also, since MojoMojo
341indicates actions by appending a C<.$action> to the path
342(e.g. C</parent_page/child_page.edit>), we remove the page path and save it in
343C<< $c->stash->{path} >> and reset C<< $c->req->path >> to C<< $action >>.
344We save the original URI in C<< $c->stash->{pre_hacked_uri} >>.
345
346=cut
347
348sub prepare_path {
349  my $c = shift;
350  $c->next::method(@_);
351  $c->stash->{pre_hacked_uri} = $c->req->uri->clone;
352  my $base = $c->req->base;
353  $base =~ s|/+$||;
354  $c->req->base(URI->new($base));
355  my ($path, $action);
356  $path = $c->req->path;
357
358  if ($path =~ /^special(?:\/|$)(.*)/) {
359    $c->stash->{path} = $path;
360    $c->req->path($1);
361  }
362  else {
363    # find the *last* period, so that pages can have periods in their name.
364    my $index = index($path, '.');
365
366    if ($index == -1) {
367
368      # no action found, default to view
369      $c->stash->{path} = $path;
370      $c->req->path('view');
371    }
372    else {
373
374      # set path in stash, and set req.path to action
375      $c->stash->{path} = substr($path, 0, $index);
376      $c->req->path(substr($path, $index + 1));
377    }
378  }
379  $c->stash->{path} = '/' . $c->stash->{path} unless ($path =~ m!^/!);
380}
381
382=head2 base_uri
383
384Return C<< $c->req->base >> as an URI object.
385
386=cut
387
388sub base_uri {
389  my $c = shift;
390  return URI->new($c->req->base);
391}
392
393=head2 uri_for
394
395Override C<< $c->uri_for >> to append path, if a relative path is used.
396
397=cut
398
399sub uri_for {
400  my $c = shift;
401  unless ($_[0] =~ m/^\//) {
402    my $val = shift @_;
403    my $prefix = $c->stash->{path} =~ m|^/| ? '' : '/';
404    unshift(@_, $prefix . $c->stash->{path} . '.' . $val);
405  }
406
407  # do I see unicode here?
408  if (Encode::is_utf8($_[0])) {
409    $_[0]
410      = join('/', map { URI::Escape::uri_escape_utf8($_) } split(/\//, $_[0]));
411  }
412
413  my $res = $c->next::method(@_);
414  $res->scheme('https') if $c->config->{'force_ssl'};
415  return $res;
416}
417
418=head2 uri_for_static
419
420C</static/> has been remapped to C</.static/>.
421
422=cut
423
424sub uri_for_static {
425  my ($self, $asset) = @_;
426  return (
427    defined($self->config->{static_path})
428    ? $self->config->{static_path} . $asset
429    : $self->uri_for('/.static', $asset));
430}
431
432=head2 _cleanup_path
433
434Lowercase the path and remove any double-slashes.
435
436=cut
437
438sub _cleanup_path {
439  my ($c, $path) = @_;
440  ## Make some changes to the path - we have to do this
441  ## because path is not always cleaned up before we get it:
442  ## sometimes we get caps, other times we don't. Permissions are
443  ## set using lowercase paths.
444
445  ## lowercase the path - and ensure it has a leading /
446  my $searchpath = lc($path);
447
448  # clear out any double-slashes
449  $searchpath =~ s|//|/|g;
450
451  return $searchpath;
452}
453
454=head2 _expand_path_elements
455
456Generate all the intermediary paths to C</path/to/a/page>, starting from C</>
457and ending with the complete path:
458
459    /
460    /path
461    /path/to
462    /path/to/a
463    /path/to/a/page
464
465=cut    
466
467sub _expand_path_elements {
468  my ($c, $path) = @_;
469  my $searchpath = $c->_cleanup_path($path);
470
471  my @pathelements = split '/', $searchpath;
472
473  if (@pathelements && $pathelements[0] eq '') {
474    shift @pathelements;
475  }
476
477  my @paths_to_check = ('/');
478
479  my $current_path = '';
480
481  foreach my $pathitem (@pathelements) {
482    $current_path .= "/" . $pathitem;
483    push @paths_to_check, $current_path;
484  }
485
486  return @paths_to_check;
487}
488
489=head2 get_permissions_data
490
491Permissions are checked prior to most actions, including C<view> if that is
492turned on in the configuration. The permission system works as follows:
493
494=over
495
496=item 1.
497
498There is a base set of rules which may be defined in the application
499config. These are:
500
501    $c->config->{permissions}{view_allowed} = 1; # or 0
502
503Similar entries exist for C<delete>, C<edit>, C<create> and C<attachment>.
504If these config variables are not defined, the default is to allow anyone
505to do anything.
506
507=item 2.
508
509Global rules that apply to everyone may be specified by creating a
510record with a role id of 0.
511
512=item 3.
513
514Rules are defined using a combination of path(s)?, and role and may be
515applied to subpages or not.
516
517TODO: clarify.
518
519=item 4.
520
521All rules matching a given user's roles and the current path are used to
522determine the final yes/no on each permission. Rules are evaluated from
523least-specific path to most specific. This means that when checking
524permissions on C</foo/bar/baz>, permission rules set for C</foo> will be
525overridden by rules set on C</foo/bar> when editing C</foo/bar/baz>. When two
526rules (from different roles) are found for the same path prefix, explicit
527C<allow>s override C<deny>s. Null entries for a given permission are always
528ignored and do not affect the permissions defined at earlier level. This
529allows you to change certain permissions (such as C<create>) only while not
530affecting previously determined permissions for the other actions. Finally -
531C<apply_to_subpages> C<yes>/C<no> is exclusive, meaning that a rule for C</foo> with
532C<apply_to_subpages> set to C<yes> will apply to C</foo/bar> but not to C</foo>
533alone. The endpoint in the path is always checked for a rule explicitly for that
534page - meaning C<apply_to_subpages = no>.
535
536=back
537
538=cut
539
540sub get_permissions_data {
541  my ($c, $current_path, $paths_to_check, $role_ids) = @_;
542
543  # default to roles for current user
544  $role_ids ||= $c->user_role_ids($c->user);
545
546  my $permdata;
547
548  ## Now that we have our path elements to check, we have to figure out how we are accessing them.
549  ## If we have caching turned on, we load the perms from the cache and walk the tree.
550  ## Otherwise we pull what we need out of the DB. The structure is:
551  # $permdata{$pagepath} = {
552  #     admin => {
553  #         page => {
554  #             create => 'yes',
555  #             delete => 'yes',
556  #             view => 'yes',
557  #             edit => 'yes',
558  #             attachment => 'yes',
559  #         },
560  #         subpages => {
561  #             create => 'yes',
562  #             delete => 'yes',
563  #             view => 'yes',
564  #             edit => 'yes',
565  #             attachment => 'yes',
566  #         },
567  #     },
568  #     users => .....
569  # }
570  if ($c->pref('cache_permission_data')) {
571    $permdata = $c->cache->get('page_permission_data');
572  }
573
574# If we don't have any permissions data, we have a problem. We need to load it.
575# We have two options here - if we are caching, we will load everything and cache it.
576# If we are not - then we load just the bits we need.
577  if (!$permdata) {
578
579    # Initialize $permdata as a reference or we end up with an error
580    # when we try to dereference it further down.  The error we're avoiding is:
581    # Can't use string ("") as a HASH ref while "strict refs"
582    $permdata = {};
583
584    ## Either the data hasn't been loaded, or it's expired since we used it last,
585    ## so we need to reload it.
586    my $rs = $c->model('DBIC::PathPermissions')
587      ->search(undef, {order_by => 'length(path),role,apply_to_subpages'});
588
589    # If we are not caching, we don't return the whole enchilada.
590    if (!$c->pref('cache_permission_data')) {
591      ## this seems odd to me - but that's what the DBIx::Class says to do.
592      $rs = $rs->search({role => $role_ids}) if $role_ids;
593      $rs = $rs->search(
594        {
595          '-or' => [
596            {path => $paths_to_check, apply_to_subpages => 'yes'},
597            {path => $current_path,   apply_to_subpages => 'no'}
598          ]
599        }
600      );
601    }
602    $rs->result_class('DBIx::Class::ResultClass::HashRefInflator');
603
604    my $recordtype;
605    while (my $record = $rs->next) {
606      if ($record->{'apply_to_subpages'} eq 'yes') {
607        $recordtype = 'subpages';
608      }
609      else {
610        $recordtype = 'page';
611      }
612      %{$permdata->{$record->{'path'}}{$record->{'role'}}{$recordtype}}
613        = map { $_ => $record->{$_ . "_allowed"} }
614        qw/create edit view delete attachment/;
615    }
616  }
617
618  ## now we re-cache it - if we need to.  # !$c->cache('memory')->exists('page_permission_data')
619  if ($c->pref('cache_permission_data')) {
620    $c->cache->set('page_permission_data', $permdata);
621  }
622
623  return $permdata;
624}
625
626=head2 user_role_ids
627
628Get the list of role ids for a user.
629
630=cut
631
632sub user_role_ids {
633  my ($c, $user) = @_;
634
635  ## always use role_id 0 - which is default role and includes everyone.
636  my @role_ids = (0);
637
638  if (ref($user)) {
639    push @role_ids, map { $_->role->id } $user->role_members->all;
640  }
641
642  return @role_ids;
643}
644
645=head2 check_permissions
646
647Check user permissions for a path.
648
649=cut
650
651sub check_permissions {
652  my ($c, $path, $user) = @_;
653
654  return {attachment => 1, create => 1, delete => 1, edit => 1, view => 1,}
655    if ($user && $user->is_admin);
656
657  # if no user is logged in
658  if (not $user) {
659
660    # if anonymous user is allowed
661    my $anonymous = $c->pref('anonymous_user');
662    if ($anonymous) {
663
664      # get anonymous user for no logged-in users
665      $user = $c->model('DBIC::Person')->search({login => $anonymous})->first;
666    }
667  }
668
669  my @paths_to_check = $c->_expand_path_elements($path);
670  my $current_path   = $paths_to_check[-1];
671
672  my @role_ids = $c->user_role_ids($user);
673
674  my $permdata
675    = $c->get_permissions_data($current_path, \@paths_to_check, \@role_ids);
676
677  # rules comparison hash
678  # allow everything by default
679  my %rulescomparison = (
680    'create' => {
681      'allowed' => $c->pref('create_allowed'),
682      'role'    => '__default',
683      'len'     => 0,
684    },
685    'delete' => {
686      'allowed' => $c->pref('delete_allowed'),
687      'role'    => '__default',
688      'len'     => 0,
689    },
690    'edit' => {
691      'allowed' => $c->pref('edit_allowed'),
692      'role'    => '__default',
693      'len'     => 0,
694    },
695    'view' => {
696      'allowed' => $c->pref('view_allowed'),
697      'role'    => '__default',
698      'len'     => 0,
699    },
700    'attachment' => {
701      'allowed' => $c->pref('attachment_allowed'),
702      'role'    => '__default',
703      'len'     => 0,
704    },
705  );
706
707  ## The outcome of this loop is a combined permission set.
708  ## The rule orders are essentially based on how specific the path
709  ## match is.  More specific paths override less specific paths.
710  ## When conflicting rules at the same level of path hierarchy
711  ## (with different roles) are discovered, the grant is given precedence
712  ## over the deny.  Note that more-specific denies will still
713  ## override.
714  my $permtype = 'subpages';
715  foreach my $i (0 .. $#paths_to_check) {
716    my $path = $paths_to_check[$i];
717    if ($i == $#paths_to_check) {
718      $permtype = 'page';
719    }
720    foreach my $role (@role_ids) {
721      if ( exists($permdata->{$path})
722        && exists($permdata->{$path}{$role})
723        && exists($permdata->{$path}{$role}{$permtype}))
724      {
725
726        my $len = length($path);
727
728        foreach my $perm (keys %{$permdata->{$path}{$role}{$permtype}}) {
729
730          ## if the xxxx_allowed column is null, this permission is ignored.
731          if (defined($permdata->{$path}{$role}{$permtype}{$perm})) {
732            if ($len == $rulescomparison{$perm}{'len'}) {
733              if ($permdata->{$path}{$role}{$permtype}{$perm} eq 'yes') {
734                $rulescomparison{$perm}{'allowed'} = 1;
735                $rulescomparison{$perm}{'len'}     = $len;
736                $rulescomparison{$perm}{'role'}    = $role;
737              }
738            }
739            elsif ($len > $rulescomparison{$perm}{'len'}) {
740              if ($permdata->{$path}{$role}{$permtype}{$perm} eq 'yes') {
741                $rulescomparison{$perm}{'allowed'} = 1;
742              }
743              else {
744                $rulescomparison{$perm}{'allowed'} = 0;
745              }
746              $rulescomparison{$perm}{'len'}  = $len;
747              $rulescomparison{$perm}{'role'} = $role;
748            }
749          }
750        }
751      }
752    }
753  }
754
755  my %perms
756    = map { $_ => $rulescomparison{$_}{'allowed'} } keys %rulescomparison;
757
758  return \%perms;
759}
760
761=head2 check_view_permission
762
763Check if a user can view a path.
764
765=cut
766
767sub check_view_permission {
768  my $c = shift;
769
770  return 1 unless $c->pref('check_permission_on_view');
771
772  my $user;
773  if ($c->user_exists()) {
774    $user = $c->user->obj;
775  }
776
777  $c->log->info('Checking permissions') if $c->debug;
778
779  my $perms = $c->check_permissions($c->stash->{path}, $user);
780  if (!$perms->{view}) {
781    $c->stash->{message}
782      = $c->loc('Permission Denied to view x', $c->stash->{page}->name);
783    $c->stash->{template} = 'message.tt';
784    return;
785  }
786
787  return 1;
788}
789
790my $search_setup_failed = 0;
791
792MojoMojo->config->{index_dir}      ||= MojoMojo->path_to('index');
793MojoMojo->config->{attachment_dir} ||= MojoMojo->path_to('uploads');
794MojoMojo->config->{root}           ||= MojoMojo->path_to('root');
795unless (-e MojoMojo->config->{index_dir}) {
796  if (not mkdir MojoMojo->config->{index_dir}) {
797    warn 'Could not make index directory <'
798      . MojoMojo->config->{index_dir}
799      . '> - FIX IT OR SEARCH WILL NOT WORK!';
800    $search_setup_failed = 1;
801  }
802}
803unless (-w MojoMojo->config->{index_dir}) {
804  warn 'Require write access to index <'
805    . MojoMojo->config->{index_dir}
806    . '> - FIX IT OR SEARCH WILL NOT WORK!';
807  $search_setup_failed = 1;
808}
809
810MojoMojo->model('Search')->prepare_search_index()
811  if not -f MojoMojo->config->{index_dir} . '/segments'
812  and not $search_setup_failed
813  and not MojoMojo->pref('disable_search');
814
815unless (-e MojoMojo->config->{attachment_dir}) {
816  mkdir MojoMojo->config->{attachment_dir}
817    or die 'Could not make attachment directory <'
818    . MojoMojo->config->{attachment_dir} . '>';
819}
820die 'Require write access to attachment_dir: <'
821  . MojoMojo->config->{attachment_dir} . '>'
822  unless -w MojoMojo->config->{attachment_dir};
823
8241;
825
826=head1 SUPPORT
827
828=over
829
830=item *
831
832L<http://mojomojo.org>
833
834=item *
835
836IRC: L<irc://irc.perl.org/mojomojo>.
837
838=item *
839
840Mailing list: L<http://mojomojo.2358427.n2.nabble.com/>
841
842=item *
843
844Commercial support and customization for MojoMojo is also provided by Nordaaker
845Ltd. Contact C<arneandmarcus@nordaaker.com> for details.
846
847=back
848
849=head1 AUTHORS
850
851Marcus Ramberg C<marcus@nordaaker.com>
852
853David Naughton C<naughton@umn.edu>
854
855Andy Grundman C<andy@hybridized.org>
856
857Jonathan Rockway C<jrockway@jrockway.us>
858
859A number of other contributors over the years:
860https://www.ohloh.net/p/mojomojo/contributors
861
862=head1 COPYRIGHT
863
864Unless explicitly stated otherwise, all modules and scripts in this distribution are:
865Copyright 2005-2010, Marcus Ramberg
866
867=head1 LICENSE
868
869You may distribute this code under the same terms as Perl itself.
870
871=cut
872