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