found & fixed another symlink attack
[ikiwiki.git] / ikiwiki
diff --git a/ikiwiki b/ikiwiki
index 54589ec2e05cc24252c59783cab9e7a3503e71b2..6b8a5153512b3a2e83bc4f4194154ce7a4f7a56e 100755 (executable)
--- a/ikiwiki
+++ b/ikiwiki
@@ -1,4 +1,7 @@
 #!/usr/bin/perl -T
+
+eval 'exec /usr/bin/perl -T -S $0 ${1+"$@"}'
+    if 0; # not running under some shell
 $ENV{PATH}="/usr/local/bin:/usr/bin:/bin";
 
 use warnings;
@@ -30,12 +33,13 @@ our %config=( #{{{
        wrappermode => undef,
        srcdir => undef,
        destdir => undef,
-       templatedir => undef,
+       templatedir => "/usr/share/ikiwiki/templates",
        setup => undef,
+       adminuser => undef,
 ); #}}}
 
 GetOptions( #{{{
-       "setup=s" => \$config{setup},
+       "setup|s=s" => \$config{setup},
        "wikiname=s" => \$config{wikiname},
        "verbose|v!" => \$config{verbose},
        "rebuild!" => \$config{rebuild},
@@ -51,21 +55,28 @@ GetOptions( #{{{
        "exclude=s@" => sub {
                $config{wiki_file_prune_regexp}=qr/$config{wiki_file_prune_regexp}|$_[1]/;
        },
+       "adminuser=s@" => sub { push @{$config{adminuser}}, $_[1] },
+       "templatedir=s" => sub { $config{templatedir}=possibly_foolish_untaint($_[1]) },
 ) || usage();
 
 if (! $config{setup}) {
-       usage() unless @ARGV == 3;
+       usage() unless @ARGV == 2;
        $config{srcdir} = possibly_foolish_untaint(shift);
-       $config{templatedir} = possibly_foolish_untaint(shift);
        $config{destdir} = possibly_foolish_untaint(shift);
+       checkoptions();
+}
+#}}}
+
+sub checkoptions { #{{{
        if ($config{cgi} && ! length $config{url}) {
                error("Must specify url to wiki with --url when using --cgi");
        }
-}
-#}}}
+       $config{wikistatedir}="$config{srcdir}/.ikiwiki"
+               unless exists $config{wikistatedir};
+} #}}}
 
 sub usage { #{{{
-       die "usage: ikiwiki [options] source templates dest\n";
+       die "usage: ikiwiki [options] source dest\n";
 } #}}}
 
 sub error { #{{{
@@ -141,6 +152,10 @@ sub htmlpage ($) { #{{{
 sub readfile ($) { #{{{
        my $file=shift;
 
+       if (-l $file) {
+               error("cannot read a symlink ($file)");
+       }
+       
        local $/=undef;
        open (IN, "$file") || error("failed to read $file: $!");
        my $ret=<IN>;
@@ -151,6 +166,10 @@ sub readfile ($) { #{{{
 sub writefile ($$) { #{{{
        my $file=shift;
        my $content=shift;
+       
+       if (-l $file) {
+               error("cannot write to a symlink ($file)");
+       }
 
        my $dir=dirname($file);
        if (! -d $dir) {
@@ -339,6 +358,7 @@ sub finalize ($$$) { #{{{
        
        if (length $config{cgiurl}) {
                $template->param(editurl => "$config{cgiurl}?do=edit&page=$page");
+               $template->param(prefsurl => "$config{cgiurl}?do=prefs");
                if ($config{svn}) {
                        $template->param(recentchangesurl => "$config{cgiurl}?do=recentchanges");
                }
@@ -409,10 +429,11 @@ sub render ($) { #{{{
 sub lockwiki () { #{{{
        # Take an exclusive lock on the wiki to prevent multiple concurrent
        # run issues. The lock will be dropped on program exit.
-       if (! -d "$config{srcdir}/.ikiwiki") {
-               mkdir("$config{srcdir}/.ikiwiki");
+       if (! -d $config{wikistatedir}) {
+               mkdir($config{wikistatedir});
        }
-       open(WIKILOCK, ">$config{srcdir}/.ikiwiki/lockfile") || error ("cannot write to lockfile: $!");
+       open(WIKILOCK, ">$config{wikistatedir}/lockfile") ||
+               error ("cannot write to $config{wikistatedir}/lockfile: $!");
        if (! flock(WIKILOCK, 2 | 4)) {
                debug("wiki seems to be locked, waiting for lock");
                my $wait=600; # arbitrary, but don't hang forever to 
@@ -430,7 +451,7 @@ sub unlockwiki () { #{{{
 } #}}}
 
 sub loadindex () { #{{{
-       open (IN, "$config{srcdir}/.ikiwiki/index") || return;
+       open (IN, "$config{wikistatedir}/index") || return;
        while (<IN>) {
                $_=possibly_foolish_untaint($_);
                chomp;
@@ -446,10 +467,11 @@ sub loadindex () { #{{{
 } #}}}
 
 sub saveindex () { #{{{
-       if (! -d "$config{srcdir}/.ikiwiki") {
-               mkdir("$config{srcdir}/.ikiwiki");
+       if (! -d $config{wikistatedir}) {
+               mkdir($config{wikistatedir});
        }
-       open (OUT, ">$config{srcdir}/.ikiwiki/index") || error("cannot write to index: $!");
+       open (OUT, ">$config{wikistatedir}/index") || 
+               error("cannot write to $config{wikistatedir}/index: $!");
        foreach my $page (keys %oldpagemtime) {
                print OUT "$oldpagemtime{$page} $pagesources{$page} $renderedfiles{$page} ".
                        join(" ", @{$links{$page}})."\n"
@@ -752,8 +774,7 @@ FILE:               foreach my $file (@files) {
        }
 } #}}}
 
-sub gen_wrapper (@) { #{{{
-       my %config=(@_);
+sub gen_wrapper () { #{{{
        eval q{use Cwd 'abs_path'};
        $config{srcdir}=abs_path($config{srcdir});
        $config{destdir}=abs_path($config{destdir});
@@ -766,8 +787,9 @@ sub gen_wrapper (@) { #{{{
                error("cannot create a wrapper that uses a setup file");
        }
        
-       my @params=($config{srcdir}, $config{templatedir}, $config{destdir},
-               "--wikiname=$config{wikiname}");
+       my @params=($config{srcdir}, $config{destdir},
+               "--wikiname=$config{wikiname}",
+               "--templatedir=$config{templatedir}");
        push @params, "--verbose" if $config{verbose};
        push @params, "--rebuild" if $config{rebuild};
        push @params, "--nosvn" if !$config{svn};
@@ -777,6 +799,7 @@ sub gen_wrapper (@) { #{{{
        push @params, "--historyurl=$config{historyurl}" if length $config{historyurl};
        push @params, "--diffurl=$config{diffurl}" if length $config{diffurl};
        push @params, "--anonok" if $config{anonok};
+       push @params, "--adminuser=$_" foreach @{$config{adminuser}};
        my $params=join(" ", @params);
        my $call='';
        foreach my $p ($this, $this, @params) {
@@ -875,26 +898,46 @@ sub userinfo_get ($$) { #{{{
        my $field=shift;
 
        eval q{use Storable};
-       my $userdata=eval{ Storable::lock_retrieve("$config{srcdir}/.ikiwiki/userdb") };
+       my $userdata=eval{ Storable::lock_retrieve("$config{wikistatedir}/userdb") };
        if (! defined $userdata || ! ref $userdata || 
-           ! exists $userdata->{$user} || ! ref $userdata->{$user}) {
+           ! exists $userdata->{$user} || ! ref $userdata->{$user} ||
+            ! exists $userdata->{$user}->{$field}) {
                return "";
        }
        return $userdata->{$user}->{$field};
 } #}}}
 
-sub userinfo_set ($$) { #{{{
+sub userinfo_set ($$$) { #{{{
+       my $user=shift;
+       my $field=shift;
+       my $value=shift;
+       
+       eval q{use Storable};
+       my $userdata=eval{ Storable::lock_retrieve("$config{wikistatedir}/userdb") };
+       if (! defined $userdata || ! ref $userdata || 
+           ! exists $userdata->{$user} || ! ref $userdata->{$user}) {
+               return "";
+       }
+       
+       $userdata->{$user}->{$field}=$value;
+       my $oldmask=umask(077);
+       my $ret=Storable::lock_store($userdata, "$config{wikistatedir}/userdb");
+       umask($oldmask);
+       return $ret;
+} #}}}
+
+sub userinfo_setall ($$) { #{{{
        my $user=shift;
        my $info=shift;
        
        eval q{use Storable};
-       my $userdata=eval{ Storable::lock_retrieve("$config{srcdir}/.ikiwiki/userdb") };
+       my $userdata=eval{ Storable::lock_retrieve("$config{wikistatedir}/userdb") };
        if (! defined $userdata || ! ref $userdata) {
                $userdata={};
        }
        $userdata->{$user}=$info;
        my $oldmask=umask(077);
-       my $ret=Storable::lock_store($userdata, "$config{srcdir}/.ikiwiki/userdb");
+       my $ret=Storable::lock_store($userdata, "$config{wikistatedir}/userdb");
        umask($oldmask);
        return $ret;
 } #}}}
@@ -905,7 +948,7 @@ sub cgi_signin ($$) { #{{{
 
        eval q{use CGI::FormBuilder};
        my $form = CGI::FormBuilder->new(
-               title => "$config{wikiname} signin",
+               title => "signin",
                fields => [qw(do page from name password confirm_password email)],
                header => 1,
                method => 'POST',
@@ -932,7 +975,7 @@ sub cgi_signin ($$) { #{{{
        $form->field(name => "confirm_password", type => "password", required => 0);
        $form->field(name => "email", required => 0);
        if ($q->param("do") ne "signin") {
-               $form->text("You need to log in before you can edit pages.");
+               $form->text("You need to log in first.");
        }
        
        if ($form->submitted) {
@@ -1010,7 +1053,7 @@ sub cgi_signin ($$) { #{{{
                }
                elsif ($form->submitted eq 'Register') {
                        my $user_name=$form->field('name');
-                       if (userinfo_set($user_name, {
+                       if (userinfo_setall($user_name, {
                                           'email' => $form->field('email'),
                                           'password' => $form->field('password'),
                                           'regdate' => time
@@ -1059,6 +1102,128 @@ sub cgi_signin ($$) { #{{{
        }
 } #}}}
 
+sub is_admin ($) { #{{{
+       my $user_name=shift;
+
+       return grep { $_ eq $user_name } @{$config{adminuser}};
+} #}}}
+
+sub glob_match ($$) { #{{{
+       my $page=shift;
+       my $glob=shift;
+
+       # turn glob into safe regexp
+       $glob=quotemeta($glob);
+       $glob=~s/\\\*/.*/g;
+       $glob=~s/\\\?/./g;
+       $glob=~s!\\/!/!g;
+       
+       $page=~/^$glob$/i;
+} #}}}
+
+sub globlist_match ($$) { #{{{
+       my $page=shift;
+       my @globlist=split(" ", shift);
+
+       # check any negated globs first
+       foreach my $glob (@globlist) {
+               return 0 if $glob=~/^!(.*)/ && glob_match($page, $1);
+       }
+
+       foreach my $glob (@globlist) {
+               return 1 if glob_match($page, $glob);
+       }
+       
+       return 0;
+} #}}}
+
+sub page_locked ($$;$) { #{{{
+       my $page=shift;
+       my $session=shift;
+       my $nonfatal=shift;
+       
+       my $user=$session->param("name");
+       return if length $user && is_admin($user);
+
+       foreach my $admin (@{$config{adminuser}}) {
+               my $locked_pages=userinfo_get($admin, "locked_pages");
+               if (globlist_match($page, userinfo_get($admin, "locked_pages"))) {
+                       return 1 if $nonfatal;
+                       error(htmllink("", $page, 1)." is locked by ".
+                             htmllink("", $admin, 1)." and cannot be edited.");
+               }
+       }
+
+       return 0;
+} #}}}
+
+sub cgi_prefs ($$) { #{{{
+       my $q=shift;
+       my $session=shift;
+
+       eval q{use CGI::FormBuilder};
+       my $form = CGI::FormBuilder->new(
+               title => "preferences",
+               fields => [qw(do name password confirm_password email locked_pages)],
+               header => 0,
+               method => 'POST',
+               validate => {
+                       confirm_password => {
+                               perl => q{eq $form->field("password")},
+                       },
+                       email => 'EMAIL',
+               },
+               required => 'NONE',
+               javascript => 0,
+               params => $q,
+               action => $q->request_uri,
+               template => (-e "$config{templatedir}/prefs.tmpl" ?
+                             "$config{templatedir}/prefs.tmpl" : "")
+       );
+       my @buttons=("Save Preferences", "Logout", "Cancel");
+       
+       my $user_name=$session->param("name");
+       $form->field(name => "do", type => "hidden");
+       $form->field(name => "name", disabled => 1,
+               value => $user_name, force => 1);
+       $form->field(name => "password", type => "password");
+       $form->field(name => "confirm_password", type => "password");
+       $form->field(name => "locked_pages", size => 50,
+               comment => "(".htmllink("", "GlobList", 1).")");
+       
+       if (! is_admin($user_name)) {
+               $form->field(name => "locked_pages", type => "hidden");
+       }
+       
+       if (! $form->submitted) {
+               $form->field(name => "email", force => 1,
+                       value => userinfo_get($user_name, "email"));
+               $form->field(name => "locked_pages", force => 1,
+                       value => userinfo_get($user_name, "locked_pages"));
+       }
+       
+       if ($form->submitted eq 'Logout') {
+               $session->delete();
+               print $q->redirect($config{url});
+               return;
+       }
+       elsif ($form->submitted eq 'Cancel') {
+               print $q->redirect($config{url});
+               return;
+       }
+       elsif ($form->submitted eq "Save Preferences" && $form->validate) {
+               foreach my $field (qw(password email locked_pages)) {
+                       if (length $form->field($field)) {
+                               userinfo_set($user_name, $field, $form->field($field)) || error("failed to set $field");
+                       }
+               }
+               $form->text("Preferences saved.");
+       }
+       
+       print $session->header();
+       print misctemplate($form->title, $form->render(submit => \@buttons));
+} #}}}
+
 sub cgi_editpage ($$) { #{{{
        my $q=shift;
        my $session=shift;
@@ -1160,8 +1325,10 @@ sub cgi_editpage ($$) { #{{{
                                        push @page_locs, $dir.$page;
                                }
 
-                               @page_locs = grep { ! exists
-                                       $pagesources{lc($_)} } @page_locs;
+                               @page_locs = grep {
+                                       ! exists $pagesources{lc($_)} &&
+                                       ! page_locked($_, $session, 1)
+                               } @page_locs;
                        }
 
                        $form->tmpl_param("page_select", 1);
@@ -1170,11 +1337,12 @@ sub cgi_editpage ($$) { #{{{
                        $form->title("creating $page");
                }
                elsif ($form->field("do") eq "edit") {
+                       page_locked($page, $session);
                        if (! defined $form->field('content') || 
                            ! length $form->field('content')) {
                                my $content="";
                                if (exists $pagesources{lc($page)}) {
-                                               $content=readfile("$config{srcdir}/$pagesources{lc($page)}");
+                                       $content=readfile("$config{srcdir}/$pagesources{lc($page)}");
                                        $content=~s/\n/\r\n/g;
                                }
                                $form->field(name => "content", value => $content,
@@ -1189,13 +1357,15 @@ sub cgi_editpage ($$) { #{{{
        }
        else {
                # save page
+               page_locked($page, $session);
+               
                my $content=$form->field('content');
                $content=~s/\r\n/\n/g;
                $content=~s/\r/\n/g;
                writefile("$config{srcdir}/$file", $content);
                
                my $message="web commit ";
-               if ($session->param("name")) {
+               if (length $session->param("name")) {
                        $message.="by ".$session->param("name");
                }
                else {
@@ -1263,12 +1433,13 @@ sub cgi () { #{{{
 
        my $oldmask=umask(077);
        my $session = CGI::Session->new("driver:db_file", $q,
-               { FileName => "$config{srcdir}/.ikiwiki/sessions.db" });
+               { FileName => "$config{wikistatedir}/sessions.db" });
        umask($oldmask);
        
        # Everything below this point needs the user to be signed in.
        if ((! $config{anonok} && ! defined $session->param("name") ||
-               ! userinfo_get($session->param("name"), "regdate")) || $do eq 'signin') {
+            ! defined $session->param("name") ||
+            ! userinfo_get($session->param("name"), "regdate")) || $do eq 'signin') {
                cgi_signin($q, $session);
        
                # Force session flush with safe umask.
@@ -1282,6 +1453,9 @@ sub cgi () { #{{{
        if ($do eq 'create' || $do eq 'edit') {
                cgi_editpage($q, $session);
        }
+       elsif ($do eq 'prefs') {
+               cgi_prefs($q, $session);
+       }
        else {
                error("unknown do parameter");
        }
@@ -1305,7 +1479,7 @@ sub setup () { # {{{
 setup() if $config{setup};
 lockwiki();
 if ($config{wrapper}) {
-       gen_wrapper(%config);
+       gen_wrapper();
        exit;
 }
 memoize('pagename');