X-Git-Url: http://git.vanrenterghem.biz/git.ikiwiki.info.git/blobdiff_plain/7bba52254569047ef1d9f5752096f155b0a2be77..c0d0482f99634c1ac156bd628d51c7f72e62e5bc:/IkiWiki.pm diff --git a/IkiWiki.pm b/IkiWiki.pm index 7382f11e4..fa71f4791 100644 --- a/IkiWiki.pm +++ b/IkiWiki.pm @@ -5,7 +5,7 @@ package IkiWiki; use warnings; use strict; use Encode; -use HTML::Entities; +use Fcntl q{:flock}; use URI::Escape q{uri_escape_utf8}; use POSIX (); use Storable; @@ -13,17 +13,17 @@ use open qw{:utf8 :std}; use vars qw{%config %links %oldlinks %pagemtime %pagectime %pagecase %pagestate %wikistate %renderedfiles %oldrenderedfiles - %pagesources %destsources %depends %depends_simple %hooks - %forcerebuild %loaded_plugins %typedlinks %oldtypedlinks - %autofiles}; + %pagesources %delpagesources %destsources %depends %depends_simple + @mass_depends %hooks %forcerebuild %loaded_plugins %typedlinks + %oldtypedlinks %autofiles @underlayfiles $lastrev $phase}; use Exporter q{import}; our @EXPORT = qw(hook debug error htmlpage template template_depends deptype add_depends pagespec_match pagespec_match_list bestlink htmllink readfile writefile pagetype srcfile pagename - displaytime will_render gettext ngettext urlto targetpage + displaytime strftime_utf8 will_render gettext ngettext urlto targetpage add_underlay pagetitle titlepage linkpage newpagefile - inject add_link add_autofile + inject add_link add_autofile useragent %config %links %pagestate %wikistate %renderedfiles %pagesources %destsources %typedlinks); our $VERSION = 3.00; # plugin interface version, next is ikiwiki version @@ -35,6 +35,11 @@ our $DEPEND_CONTENT=1; our $DEPEND_PRESENCE=2; our $DEPEND_LINKS=4; +# Phases of processing. +sub PHASE_SCAN () { 0 } +sub PHASE_RENDER () { 1 } +$phase = PHASE_SCAN; + # Optimisation. use Memoize; memoize("abs2rel"); @@ -104,6 +109,14 @@ sub getsetup () { safe => 1, rebuild => 1, }, + reverse_proxy => { + type => "boolean", + default => 0, + description => "do not adjust cgiurl if CGI is accessed via different URL", + advanced => 0, + safe => 1, + rebuild => 0, # only affects CGI requests + }, cgi_wrapper => { type => "string", default => '', @@ -119,6 +132,29 @@ sub getsetup () { safe => 0, rebuild => 0, }, + cgi_overload_delay => { + type => "string", + default => '', + example => "10", + description => "number of seconds to delay CGI requests when overloaded", + safe => 1, + rebuild => 0, + }, + cgi_overload_message => { + type => "string", + default => '', + example => "Please wait", + description => "message to display when overloaded (may contain html)", + safe => 1, + rebuild => 0, + }, + only_committed_changes => { + type => "boolean", + default => 0, + description => "enable optimization of only refreshing committed changes?", + safe => 1, + rebuild => 0, + }, rcs => { type => "string", default => '', @@ -130,7 +166,8 @@ sub getsetup () { type => "internal", default => [qw{mdwn link inline meta htmlscrubber passwordauth openid signinedit lockedit conditional - recentchanges parentlinks editpage}], + recentchanges parentlinks editpage + templatebody}], description => "plugins to enable by default", safe => 0, rebuild => 1, @@ -152,7 +189,7 @@ sub getsetup () { templatedir => { type => "string", default => "$installdir/share/ikiwiki/templates", - description => "location of template files", + description => "additional directory to search for template files", advanced => 1, safe => 0, # path rebuild => 1, @@ -235,6 +272,14 @@ sub getsetup () { safe => 1, rebuild => 1, }, + html5 => { + type => "boolean", + default => 0, + description => "use elements new in HTML5 like
?", + advanced => 0, + safe => 1, + rebuild => 1, + }, sslcookie => { type => "boolean", default => 0, @@ -298,9 +343,9 @@ sub getsetup () { rebuild => 0, }, umask => { - type => "integer", - example => "022", - description => "force ikiwiki to use a particular umask", + type => "string", + example => "public", + description => "force ikiwiki to use a particular umask (keywords public, group or private, or a number)", advanced => 1, safe => 0, # paranoia rebuild => 0, @@ -313,11 +358,20 @@ sub getsetup () { safe => 0, # paranoia rebuild => 0, }, + libdirs => { + type => "string", + default => [], + example => ["$ENV{HOME}/.local/share/ikiwiki"], + description => "extra library and plugin directories", + advanced => 1, + safe => 0, # directory + rebuild => 0, + }, libdir => { type => "string", default => "", example => "$ENV{HOME}/.ikiwiki/", - description => "extra library and plugin directory", + description => "extra library and plugin directory (searched after libdirs)", advanced => 1, safe => 0, # directory rebuild => 0, @@ -329,6 +383,14 @@ sub getsetup () { safe => 0, # paranoia rebuild => 0, }, + timezone => { + type => "string", + default => "", + example => "US/Eastern", + description => "time zone name", + safe => 1, + rebuild => 1, + }, include => { type => "string", default => undef, @@ -434,6 +496,13 @@ sub getsetup () { safe => 0, rebuild => 0, }, + wrapper_background_command => { + type => "internal", + default => '', + description => "background shell command to run", + safe => 0, + rebuild => 0, + }, gettime => { type => "internal", description => "running in gettime mode", @@ -463,7 +532,7 @@ sub getsetup () { }, setuptype => { type => "internal", - default => "Standard", + default => "Yaml", description => "perl class to use to dump setup file", safe => 0, rebuild => 0, @@ -475,6 +544,47 @@ sub getsetup () { safe => 0, rebuild => 0, }, + cookiejar => { + type => "string", + default => { file => "$ENV{HOME}/.ikiwiki/cookies" }, + description => "cookie control", + safe => 0, # hooks into perl module internals + rebuild => 0, + }, + useragent => { + type => "string", + default => "ikiwiki/$version", + example => "Wget/1.13.4 (linux-gnu)", + description => "set custom user agent string for outbound HTTP requests e.g. when fetching aggregated RSS feeds", + safe => 0, + rebuild => 0, + }, + responsive_layout => { + type => "boolean", + default => 1, + description => "theme has a responsive layout? (mobile-optimized)", + safe => 1, + rebuild => 1, + }, + deterministic => { + type => "boolean", + default => 0, + description => "try harder to produce deterministic output", + safe => 1, + rebuild => 1, + advanced => 1, + }, +} + +sub getlibdirs () { + my @libdirs; + if ($config{libdirs}) { + @libdirs = @{$config{libdirs}}; + } + if (length $config{libdir}) { + push @libdirs, $config{libdir}; + } + return @libdirs; } sub defaultconfig () { @@ -483,10 +593,15 @@ sub defaultconfig () { foreach my $key (keys %s) { push @ret, $key, $s{$key}->{default}; } - use Data::Dumper; return @ret; } +# URL to top of wiki as a path starting with /, valid from any wiki page or +# the CGI; if that's not possible, an absolute URL. Either way, it ends with / +my $local_url; +# URL to CGI script, similar to $local_url +my $local_cgiurl; + sub checkconfig () { # locale stuff; avoid LC_ALL since it overrides everything if (defined $ENV{LC_ALL}) { @@ -509,6 +624,23 @@ sub checkconfig () { $ENV{$val}=$config{ENV}{$val}; } } + if (defined $config{timezone} && length $config{timezone}) { + $ENV{TZ}=$config{timezone}; + } + elsif (defined $ENV{TZ} && length $ENV{TZ}) { + $config{timezone}=$ENV{TZ}; + } + else { + eval q{use Config qw()}; + error($@) if $@; + + if ($Config::Config{d_gnulibc} && -e '/etc/localtime') { + $config{timezone}=$ENV{TZ}=':/etc/localtime'; + } + else { + $config{timezone}=$ENV{TZ}='GMT'; + } + } if ($config{w3mmode}) { eval q{use Cwd q{abs_path}}; @@ -523,12 +655,81 @@ sub checkconfig () { if ($config{cgi} && ! length $config{url}) { error(gettext("Must specify url to wiki with --url when using --cgi")); } - + + if (defined $config{url} && length $config{url}) { + eval q{use URI}; + my $baseurl = URI->new($config{url}); + + $local_url = $baseurl->path . "/"; + $local_cgiurl = undef; + + if (length $config{cgiurl}) { + my $cgiurl = URI->new($config{cgiurl}); + + $local_cgiurl = $cgiurl->path; + + if ($cgiurl->scheme eq 'https' && + $baseurl->scheme eq 'http') { + # We assume that the same content is available + # over both http and https, because if it + # wasn't, accessing the static content + # from the CGI would be mixed-content, + # which would be a security flaw. + + if ($cgiurl->authority ne $baseurl->authority) { + # use protocol-relative URL for + # static content + $local_url = "$config{url}/"; + $local_url =~ s{^http://}{//}; + } + # else use host-relative URL for static content + + # either way, CGI needs to be absolute + $local_cgiurl = $config{cgiurl}; + } + elsif ($cgiurl->scheme ne $baseurl->scheme) { + # too far apart, fall back to absolute URLs + $local_url = "$config{url}/"; + $local_cgiurl = $config{cgiurl}; + } + elsif ($cgiurl->authority ne $baseurl->authority) { + # slightly too far apart, fall back to + # protocol-relative URLs + $local_url = "$config{url}/"; + $local_url =~ s{^https?://}{//}; + $local_cgiurl = $config{cgiurl}; + $local_cgiurl =~ s{^https?://}{//}; + } + # else keep host-relative URLs + } + + $local_url =~ s{//$}{/}; + } + else { + $local_cgiurl = $config{cgiurl}; + } + $config{wikistatedir}="$config{srcdir}/.ikiwiki" unless exists $config{wikistatedir} && defined $config{wikistatedir}; if (defined $config{umask}) { - umask(possibly_foolish_untaint($config{umask})); + my $u = possibly_foolish_untaint($config{umask}); + + if ($u =~ m/^\d+$/) { + umask($u); + } + elsif ($u eq 'private') { + umask(077); + } + elsif ($u eq 'group') { + umask(027); + } + elsif ($u eq 'public') { + umask(022); + } + else { + error(sprintf(gettext("unsupported umask setting %s"), $u)); + } } run_hooks(checkconfig => sub { shift->() }); @@ -539,14 +740,14 @@ sub checkconfig () { sub listplugins () { my %ret; - foreach my $dir (@INC, $config{libdir}) { + foreach my $dir (@INC, getlibdirs()) { next unless defined $dir && length $dir; foreach my $file (glob("$dir/IkiWiki/Plugin/*.pm")) { my ($plugin)=$file=~/.*\/(.*)\.pm$/; $ret{$plugin}=1; } } - foreach my $dir ($config{libdir}, "$installdir/lib/ikiwiki") { + foreach my $dir (getlibdirs(), "$installdir/lib/ikiwiki") { next unless defined $dir && length $dir; foreach my $file (glob("$dir/plugins/*")) { $ret{basename($file)}=1 if -x $file; @@ -557,8 +758,8 @@ sub listplugins () { } sub loadplugins () { - if (defined $config{libdir} && length $config{libdir}) { - unshift @INC, possibly_foolish_untaint($config{libdir}); + foreach my $dir (getlibdirs()) { + unshift @INC, possibly_foolish_untaint($dir); } foreach my $plugin (@{$config{default_plugins}}, @{$config{add_plugins}}) { @@ -585,13 +786,14 @@ sub loadplugins () { return 1; } -sub loadplugin ($) { +sub loadplugin ($;$) { my $plugin=shift; + my $force=shift; - return if grep { $_ eq $plugin} @{$config{disable_plugins}}; + return if ! $force && grep { $_ eq $plugin} @{$config{disable_plugins}}; - foreach my $dir (defined $config{libdir} ? possibly_foolish_untaint($config{libdir}) : undef, - "$installdir/lib/ikiwiki") { + foreach my $possiblytainteddir (getlibdirs(), "$installdir/lib/ikiwiki") { + my $dir = possibly_foolish_untaint($possiblytainteddir); if (defined $dir && -x "$dir/plugins/$plugin") { eval { require IkiWiki::Plugin::external }; if ($@) { @@ -629,6 +831,7 @@ sub debug ($) { } my $log_open=0; +my $log_failed=0; sub log_message ($$) { my $type=shift; @@ -639,9 +842,17 @@ sub log_message ($$) { Sys::Syslog::openlog('ikiwiki', '', 'user'); $log_open=1; } - return eval { - Sys::Syslog::syslog($type, "[$config{wikiname}] %s", join(" ", @_)); + eval { + my $message = "[$config{wikiname}] ".join(" ", @_); + utf8::encode($message); + Sys::Syslog::syslog($type, "%s", $message); }; + if ($@) { + print STDERR "failed to syslog: $@" unless $log_failed; + $log_failed=1; + print STDERR "@_\n"; + } + return $@; } elsif (! $config{cgi}) { return print "@_\n"; @@ -702,7 +913,7 @@ sub pagename ($) { my $type=pagetype($file); my $page=$file; - $page=~s/\Q.$type\E*$// + $page=~s/\Q.$type\E*$// if defined $type && !$hooks{htmlize}{$type}{keepextension} && !$hooks{htmlize}{$type}{noextension}; if ($config{indexpages} && $page=~/(.*)\/index$/) { @@ -763,17 +974,23 @@ sub srcfile ($;$) { return (srcfile_stat(@_))[0]; } -sub add_underlay ($) { +sub add_literal_underlay ($) { my $dir=shift; - if ($dir !~ /^\//) { - $dir="$config{underlaydirbase}/$dir"; - } - if (! grep { $_ eq $dir } @{$config{underlaydirs}}) { unshift @{$config{underlaydirs}}, $dir; } +} + +sub add_underlay ($) { + my $dir = shift; + if ($dir !~ /^\//) { + $dir="$config{underlaydirbase}/$dir"; + } + + add_literal_underlay($dir); + # why does it return 1? we just don't know return 1; } @@ -808,6 +1025,17 @@ sub prep_writefile ($$) { if (-l "$destdir/$test") { error("cannot write to a symlink ($test)"); } + if (-f _ && $test ne $file) { + # Remove conflicting file. + foreach my $p (keys %renderedfiles, keys %oldrenderedfiles) { + foreach my $f (@{$renderedfiles{$p}}, @{$oldrenderedfiles{$p}}) { + if ($f eq $test) { + unlink("$destdir/$test"); + last; + } + } + } + } $test=dirname($test); } @@ -861,10 +1089,36 @@ sub will_render ($$;$) { my $dest=shift; my $clear=shift; - # Important security check. + # Important security check for independently created files. if (-e "$config{destdir}/$dest" && ! $config{rebuild} && ! grep { $_ eq $dest } (@{$renderedfiles{$page}}, @{$oldrenderedfiles{$page}}, @{$wikistate{editpage}{previews}})) { - error("$config{destdir}/$dest independently created, not overwriting with version from $page"); + my $from_other_page=0; + # Expensive, but rarely runs. + foreach my $p (keys %renderedfiles, keys %oldrenderedfiles) { + if (grep { + $_ eq $dest || + dirname($_) eq $dest + } @{$renderedfiles{$p}}, @{$oldrenderedfiles{$p}}) { + $from_other_page=1; + last; + } + } + + error("$config{destdir}/$dest independently created, not overwriting with version from $page") + unless $from_other_page; + } + + # If $dest exists as a directory, remove conflicting files in it + # rendered from other pages. + if (-d _) { + foreach my $p (keys %renderedfiles, keys %oldrenderedfiles) { + foreach my $f (@{$renderedfiles{$p}}, @{$oldrenderedfiles{$p}}) { + if (dirname($f) eq $dest) { + unlink("$config{destdir}/$f"); + rmdir(dirname("$config{destdir}/$f")); + } + } + } } if (! $clear || $cleared{$page}) { @@ -923,7 +1177,7 @@ sub bestlink ($$) { sub isinlinableimage ($) { my $file=shift; - return $file =~ /\.(png|gif|jpg|jpeg)$/i; + return $file =~ /\.(png|gif|jpg|jpeg|svg)$/i; } sub pagetitle ($;$) { @@ -958,19 +1212,30 @@ sub linkpage ($) { sub cgiurl (@) { my %params=@_; - my $cgiurl=$config{cgiurl}; + my $cgiurl=$local_cgiurl; + if (exists $params{cgiurl}) { $cgiurl=$params{cgiurl}; delete $params{cgiurl}; } + + unless (%params) { + return $cgiurl; + } + return $cgiurl."?". - join("&", map $_."=".uri_escape_utf8($params{$_}), keys %params); + join("&", map $_."=".uri_escape_utf8($params{$_}), sort(keys %params)); +} + +sub cgiurl_abs (@) { + eval q{use URI}; + URI->new_abs(cgiurl(@_), $config{cgiurl}); } sub baseurl (;$) { my $page=shift; - return "$config{url}/" if ! defined $page; + return $local_url if ! defined $page; $page=htmlpage($page); $page=~s/[^\/]+$//; @@ -978,6 +1243,16 @@ sub baseurl (;$) { return $page; } +sub urlabs ($$) { + my $url=shift; + my $urlbase=shift; + + return $url unless defined $urlbase && length $urlbase; + + eval q{use URI}; + URI->new_abs($url, $urlbase)->as_string; +} + sub abs2rel ($$) { # Work around very innefficient behavior in File::Spec if abs2rel # is passed two relative paths. It's much faster if paths are @@ -991,10 +1266,18 @@ sub abs2rel ($$) { return $ret; } -sub displaytime ($;$) { +sub displaytime ($;$$) { # Plugins can override this function to mark up the time to # display. - return ''.formattime(@_).''; + my $time=formattime($_[0], $_[1]); + if ($config{html5}) { + return ''; + } + else { + return ''.$time.''; + } } sub formattime ($;$) { @@ -1005,9 +1288,29 @@ sub formattime ($;$) { $format=$config{timeformat}; } + return strftime_utf8($format, localtime($time)); +} + +my $strftime_encoding; +sub strftime_utf8 { # strftime doesn't know about encodings, so make sure - # its output is properly treated as utf8 - return decode_utf8(POSIX::strftime($format, localtime($time))); + # its output is properly treated as utf8. + # Note that this does not handle utf-8 in the format string. + ($strftime_encoding) = POSIX::setlocale(&POSIX::LC_TIME) =~ m#\.([^@]+)# + unless defined $strftime_encoding; + $strftime_encoding + ? Encode::decode($strftime_encoding, POSIX::strftime(@_)) + : POSIX::strftime(@_); +} + +sub date_3339 ($) { + my $time=shift; + + my $lc_time=POSIX::setlocale(&POSIX::LC_TIME); + POSIX::setlocale(&POSIX::LC_TIME, "C"); + my $ret=POSIX::strftime("%Y-%m-%dT%H:%M:%SZ", gmtime($time)); + POSIX::setlocale(&POSIX::LC_TIME, $lc_time); + return $ret; } sub beautify_urlpath ($) { @@ -1026,13 +1329,13 @@ sub beautify_urlpath ($) { return $url; } -sub urlto ($$;$) { +sub urlto ($;$$) { my $to=shift; my $from=shift; my $absolute=shift; if (! length $to) { - return beautify_urlpath(baseurl($from)."index.$config{htmlext}"); + $to = 'index'; } if (! $destsources{$to}) { @@ -1043,11 +1346,26 @@ sub urlto ($$;$) { return $config{url}.beautify_urlpath("/".$to); } + if (! defined $from) { + my $u = $local_url || ''; + $u =~ s{/$}{}; + return $u.beautify_urlpath("/".$to); + } + my $link = abs2rel($to, dirname(htmlpage($from))); return beautify_urlpath($link); } +sub isselflink ($$) { + # Plugins can override this function to support special types + # of selflinks. + my $page=shift; + my $link=shift; + + return $page eq $link; +} + sub htmllink ($$$;@) { my $lpage=shift; # the page doing the linking my $page=shift; # the page that will contain the link (different for inline) @@ -1073,7 +1391,7 @@ sub htmllink ($$$;@) { } return "$linktext" - if length $bestlink && $page eq $bestlink && + if length $bestlink && isselflink($page, $bestlink) && ! defined $opts{anchor}; if (! $destsources{$bestlink}) { @@ -1085,7 +1403,7 @@ sub htmllink ($$$;@) { $cgilink = " "create", - page => lc($link), + page => $link, from => $lpage )."\" rel=\"nofollow\">?"; } @@ -1119,10 +1437,11 @@ sub userpage ($) { return length $config{userdir} ? "$config{userdir}/$user" : $user; } +# Username to display for openid accounts. sub openiduser ($) { my $user=shift; - if ($user =~ m!^https?://! && + if (defined $user && $user =~ m!^https?://! && eval q{use Net::OpenID::VerifiedIdentity; 1} && !$@) { my $display; @@ -1153,6 +1472,36 @@ sub openiduser ($) { return; } +# Username to display for emailauth accounts. +sub emailuser ($) { + my $user=shift; + if (defined $user && $user =~ m/(.+)@/) { + my $nick=$1; + # remove any characters from not allowed in wiki files + # support use w/o %config set + my $chars = defined $config{wiki_file_chars} ? $config{wiki_file_chars} : "-[:alnum:]+/.:_"; + $nick=~s/[^$chars]/_/g; + return $nick; + } + return; +} + +# Some user information should not be exposed in commit metadata, etc. +# This generates a cloaked form of such information. +sub cloak ($) { + my $user=shift; + # cloak email address using http://xmlns.com/foaf/spec/#term_mbox_sha1sum + if ($user=~m/(.+)@/) { + my $nick=$1; + eval q{use Digest::SHA}; + return $user if $@; + return $nick.'@'.Digest::SHA::sha1_hex("mailto:$user"); + } + else { + return $user; + } +} + sub htmlize ($$$$) { my $page=shift; my $destpage=shift; @@ -1234,16 +1583,21 @@ sub preprocess ($$$;$$) { # consider it significant. my @params; while ($params =~ m{ - (?:([-\w]+)=)? # 1: named parameter key? + (?:([-.\w]+)=)? # 1: named parameter key? (?: """(.*?)""" # 2: triple-quoted value | "([^"]*?)" # 3: single-quoted value | - (\S+) # 4: unquoted value + '''(.*?)''' # 4: triple-single-quote + | + <<([a-zA-Z]+)\n # 5: heredoc start + (.*?)\n\5 # 6: heredoc value + | + (\S+) # 7: unquoted value ) (?:\s+|$) # delimiter to next param - }sgx) { + }msgx) { my $key=$1; my $val; if (defined $2) { @@ -1258,6 +1612,12 @@ sub preprocess ($$$;$$) { elsif (defined $4) { $val=$4; } + elsif (defined $7) { + $val=$7; + } + elsif (defined $6) { + $val=$6; + } if (defined $key) { push @params, $key, $val; @@ -1266,7 +1626,7 @@ sub preprocess ($$$;$$) { push @params, $val, ''; } } - if ($preprocessing{$page}++ > 3) { + if ($preprocessing{$page}++ > 8) { # Avoid loops of preprocessed pages preprocessing # other pages that preprocess them, etc. return "[[!$command ". @@ -1287,6 +1647,8 @@ sub preprocess ($$$;$$) { if ($@) { my $error=$@; chomp $error; + eval q{use HTML::Entities}; + $error = encode_entities($error); $ret="[[!$command ". gettext("Error").": $error"."]]"; } @@ -1320,12 +1682,17 @@ sub preprocess ($$$;$$) { ( # 4: the parameters.. \s+ # Must have space if parameters present (?: - (?:[-\w]+=)? # named parameter key? + (?:[-.\w]+=)? # named parameter key? (?: """.*?""" # triple-quoted value | "[^"]*?" # single-quoted value | + '''.*?''' # triple-single-quote + | + <<([a-zA-Z]+)\n # 5: heredoc start + (?:.*?)\n\5 # heredoc value + | [^"\s\]]+ # unquoted value ) \s* # whitespace or end @@ -1343,12 +1710,17 @@ sub preprocess ($$$;$$) { \s+ ( # 4: the parameters.. (?: - (?:[-\w]+=)? # named parameter key? + (?:[-.\w]+=)? # named parameter key? (?: """.*?""" # triple-quoted value | "[^"]*?" # single-quoted value | + '''.*?''' # triple-single-quote + | + <<([a-zA-Z]+)\n # 5: heredoc start + (?:.*?)\n\5 # heredoc value + | [^"\s\]]+ # unquoted value ) \s* # whitespace or end @@ -1376,10 +1748,6 @@ sub filter ($$$) { return $content; } -sub indexlink () { - return "$config{wikiname}"; -} - sub check_canedit ($$$;$) { my $page=shift; my $q=shift; @@ -1444,6 +1812,69 @@ sub check_content (@) { return defined $ok ? $ok : 1; } +sub check_canchange (@) { + my %params = @_; + my $cgi = $params{cgi}; + my $session = $params{session}; + my @changes = @{$params{changes}}; + + my %newfiles; + foreach my $change (@changes) { + # This untaint is safe because we check file_pruned and + # wiki_file_regexp. + my ($file)=$change->{file}=~/$config{wiki_file_regexp}/; + $file=possibly_foolish_untaint($file); + if (! defined $file || ! length $file || + file_pruned($file)) { + error(gettext("bad file name %s"), $file); + } + + my $type=pagetype($file); + my $page=pagename($file) if defined $type; + + if ($change->{action} eq 'add') { + $newfiles{$file}=1; + } + + if ($change->{action} eq 'change' || + $change->{action} eq 'add') { + if (defined $page) { + check_canedit($page, $cgi, $session); + next; + } + else { + if (IkiWiki::Plugin::attachment->can("check_canattach")) { + IkiWiki::Plugin::attachment::check_canattach($session, $file, $change->{path}); + check_canedit($file, $cgi, $session); + next; + } + } + } + elsif ($change->{action} eq 'remove') { + # check_canremove tests to see if the file is present + # on disk. This will fail when a single commit adds a + # file and then removes it again. Avoid the problem + # by not testing the removal in such pairs of changes. + # (The add is still tested, just to make sure that + # no data is added to the repo that a web edit + # could not add.) + next if $newfiles{$file}; + + if (IkiWiki::Plugin::remove->can("check_canremove")) { + IkiWiki::Plugin::remove::check_canremove(defined $page ? $page : $file, $cgi, $session); + check_canedit(defined $page ? $page : $file, $cgi, $session); + next; + } + } + else { + error "unknown action ".$change->{action}; + } + + error sprintf(gettext("you are not allowed to change %s"), $file); + } +} + + my $wikilock; sub lockwiki () { @@ -1454,8 +1885,11 @@ sub lockwiki () { } open($wikilock, '>', "$config{wikistatedir}/lockfile") || error ("cannot write to $config{wikistatedir}/lockfile: $!"); - if (! flock($wikilock, 2)) { # LOCK_EX - error("failed to get lock"); + if (! flock($wikilock, LOCK_EX | LOCK_NB)) { + debug("failed to get lock; waiting..."); + if (! flock($wikilock, LOCK_EX)) { + error("failed to get lock"); + } } return 1; } @@ -1495,7 +1929,8 @@ sub enable_commit_hook () { sub loadindex () { %oldrenderedfiles=%pagectime=(); - if (! $config{rebuild}) { + my $rebuild=$config{rebuild}; + if (! $rebuild) { %pagesources=%pagemtime=%oldlinks=%links=%depends= %destsources=%renderedfiles=%pagecase=%pagestate= %depends_simple=%typedlinks=%oldtypedlinks=(); @@ -1507,7 +1942,8 @@ sub loadindex () { open ($in, "<", "$config{wikistatedir}/indexdb") || return; } else { - $config{gettime}=1; # first build + # gettime on first build + $config{gettime}=1 unless defined $config{gettime}; return; } } @@ -1521,6 +1957,12 @@ sub loadindex () { if (exists $index->{version} && ! ref $index->{version}) { $pages=$index->{page}; %wikistate=%{$index->{state}}; + # Handle plugins that got disabled by loading a new setup. + if (exists $config{setupfile}) { + require IkiWiki::Setup; + IkiWiki::Setup::disabled_plugins( + grep { ! $loaded_plugins{$_} } keys %wikistate); + } } else { $pages=$index; @@ -1529,10 +1971,16 @@ sub loadindex () { foreach my $src (keys %$pages) { my $d=$pages->{$src}; - my $page=pagename($src); + my $page; + if (exists $d->{page} && ! $rebuild) { + $page=$d->{page}; + } + else { + $page=pagename($src); + } $pagectime{$page}=$d->{ctime}; - if (! $config{rebuild}) { - $pagesources{$page}=$src; + $pagesources{$page}=$src; + if (! $rebuild) { $pagemtime{$page}=$d->{mtime}; $renderedfiles{$page}=$d->{dest}; if (exists $d->{links} && ref $d->{links}) { @@ -1582,17 +2030,15 @@ sub loadindex () { foreach my $page (keys %renderedfiles) { $destsources{$_}=$page foreach @{$renderedfiles{$page}}; } + $lastrev=$index->{lastrev}; + @underlayfiles=@{$index->{underlayfiles}} if ref $index->{underlayfiles}; return close($in); } sub saveindex () { run_hooks(savestate => sub { shift->() }); - my %hookids; - foreach my $type (keys %hooks) { - $hookids{$_}=1 foreach keys %{$hooks{$type}}; - } - my @hookids=keys %hookids; + my @plugins=keys %loaded_plugins; if (! -d $config{wikistatedir}) { mkdir($config{wikistatedir}); @@ -1607,6 +2053,7 @@ sub saveindex () { my $src=$pagesources{$page}; $index{page}{$src}={ + page => $page, ctime => $pagectime{$page}, mtime => $pagemtime{$page}, dest => $renderedfiles{$page}, @@ -1626,21 +2073,21 @@ sub saveindex () { } if (exists $pagestate{$page}) { - foreach my $id (@hookids) { - foreach my $key (keys %{$pagestate{$page}{$id}}) { - $index{page}{$src}{state}{$id}{$key}=$pagestate{$page}{$id}{$key}; - } - } + $index{page}{$src}{state}=$pagestate{$page}; } } $index{state}={}; - foreach my $id (@hookids) { + foreach my $id (@plugins) { + $index{state}{$id}={}; # used to detect disabled plugins foreach my $key (keys %{$wikistate{$id}}) { $index{state}{$id}{$key}=$wikistate{$id}{$key}; } } + $index{lastrev}=$lastrev; + $index{underlayfiles}=\@underlayfiles; + $index{version}="3"; my $ret=Storable::nstore_fd(\%index, $out); return if ! defined $ret || ! $ret; @@ -1654,14 +2101,21 @@ sub saveindex () { sub template_file ($) { my $name=shift; - my $tpage=($name =~ /^\//) ? $name : "templates/$name"; + my $tpage=($name =~ s/^\///) ? $name : "templates/$name"; + my $template; if ($name !~ /\.tmpl$/ && exists $pagesources{$tpage}) { - $tpage=$pagesources{$tpage}; + $template=srcfile($pagesources{$tpage}, 1); $name.=".tmpl"; } + else { + $template=srcfile($tpage, 1); + } - my $template=srcfile($tpage, 1); - if (! defined $template) { + if (defined $template) { + return $template, $tpage, 1 if wantarray; + return $template; + } + else { $name=~s:/::; # avoid path traversal foreach my $dir ($config{templatedir}, "$installdir/share/ikiwiki/templates") { @@ -1670,12 +2124,12 @@ sub template_file ($) { last; } } + if (defined $template) { + return $template, $tpage if wantarray; + return $template; + } } - if (defined $template) { - return $template, $tpage if wantarray; - return $template; - } return; } @@ -1683,47 +2137,65 @@ sub template_depends ($$;@) { my $name=shift; my $page=shift; - my ($filename, $tpage)=template_file($name); + my ($filename, $tpage, $untrusted)=template_file($name); + if (! defined $filename) { + error(sprintf(gettext("template %s not found"), $name)) + } + if (defined $page && defined $tpage) { add_depends($page, $tpage); } - return unless defined $filename; - - require HTML::Template; - return HTML::Template->new( + my @opts=( filter => sub { my $text_ref = shift; ${$text_ref} = decode_utf8(${$text_ref}); + run_hooks(readtemplate => sub { + ${$text_ref} = shift->( + id => $name, + page => $tpage, + content => ${$text_ref}, + untrusted => $untrusted, + ); + }); }, loop_context_vars => 1, die_on_bad_params => 0, + parent_global_vars => 1, filename => $filename, @_, - no_includes => 1, + ($untrusted ? (no_includes => 1) : ()), ); + return @opts if wantarray; + + require HTML::Template; + return HTML::Template->new(@opts); } sub template ($;@) { template_depends(shift, undef, @_); } -sub misctemplate ($$;@) { - my $title=shift; - my $pagebody=shift; - - my $template=template("misc.tmpl", - title => $title, - indexlink => indexlink(), - wikiname => $config{wikiname}, - pagebody => $pagebody, - baseurl => baseurl(), - @_, - ); - run_hooks(pagetemplate => sub { - shift->(page => "", destpage => "", template => $template); +sub templateactions ($$) { + my $template=shift; + my $page=shift; + + my $have_actions=0; + my @actions; + run_hooks(pageactions => sub { + push @actions, map { { action => $_ } } + grep { defined } shift->(page => $page); }); - return $template->output; + $template->param(actions => \@actions); + + if ($config{cgiurl} && exists $hooks{auth}) { + $template->param(prefsurl => cgiurl(do => "prefs")); + $have_actions=1; + } + + if ($have_actions || @actions) { + $template->param(have_actions => 1); + } } sub hook (@) { @@ -1774,11 +2246,11 @@ sub rcs_prepedit ($) { $hooks{rcs}{rcs_prepedit}{call}->(@_); } -sub rcs_commit ($$$;$$) { +sub rcs_commit (@) { $hooks{rcs}{rcs_commit}{call}->(@_); } -sub rcs_commit_staged ($$$) { +sub rcs_commit_staged (@) { $hooks{rcs}{rcs_commit_staged}{call}->(@_); } @@ -1798,7 +2270,7 @@ sub rcs_recentchanges ($) { $hooks{rcs}{rcs_recentchanges}{call}->(@_); } -sub rcs_diff ($) { +sub rcs_diff ($;$) { $hooks{rcs}{rcs_diff}{call}->(@_); } @@ -1975,6 +2447,14 @@ sub add_autofile ($$$) { $autofiles{$file}{generator}=$generator; } +sub useragent () { + return LWP::UserAgent->new( + cookie_jar => $config{cookiejar}, + env_proxy => 1, # respect proxy env vars + agent => $config{useragent}, + ); +} + sub sortspec_translate ($$) { my $spec = shift; my $reverse = shift; @@ -2111,6 +2591,19 @@ sub pagespec_match ($$;@) { return $sub->($page, @params); } +# e.g. @pages = sort_pages("title", \@pages, reverse => "yes") +# +# Not exported yet, but could be in future if it is generally useful. +# Note that this signature is not the same as IkiWiki::SortSpec::sort_pages, +# which is "more internal". +sub sort_pages ($$;@) { + my $sort = shift; + my $list = shift; + my %params = @_; + $sort = sortspec_translate($sort, $params{reverse}); + return IkiWiki::SortSpec::sort_pages($sort, @$list); +} + sub pagespec_match_list ($$;@) { my $page=shift; my $pagespec=shift; @@ -2198,7 +2691,7 @@ sub glob2re ($) { my $re=quotemeta(shift); $re=~s/\\\*/.*/g; $re=~s/\\\?/./g; - return $re; + return qr/^$re$/i; } package IkiWiki::FailReason; @@ -2216,21 +2709,48 @@ our @ISA = 'IkiWiki::SuccessReason'; package IkiWiki::SuccessReason; +# A blessed array-ref: +# +# [0]: human-readable reason for success (or, in FailReason subclass, failure) +# [1]{""}: +# - if absent or false, the influences of this evaluation are "static", +# see the influences_static method +# - if true, they are dynamic (not static) +# [1]{any other key}: +# the dependency types of influences, as returned by the influences method + use overload ( + # in string context, it's the human-readable reason '""' => sub { $_[0][0] }, + # in boolean context, SuccessReason is 1 and FailReason is 0 '0+' => sub { 1 }, + # negating a result gives the opposite result with the same influences '!' => sub { bless $_[0], 'IkiWiki::FailReason'}, + # A & B = (A ? B : A) with the influences of both '&' => sub { $_[1]->merge_influences($_[0], 1); $_[1] }, + # A | B = (A ? A : B) with the influences of both '|' => sub { $_[0]->merge_influences($_[1]); $_[0] }, fallback => 1, ); +# SuccessReason->new("human-readable reason", page => deptype, ...) + sub new { my $class = shift; my $value = shift; return bless [$value, {@_}], $class; } +# influences(): return a reference to a copy of the hash +# { page => dependency type } describing the pages that indirectly influenced +# this result, but would not cause a dependency through ikiwiki's core +# dependency logic. +# +# See [[todo/dependency_types]] for extensive discussion of what this means. +# +# influences(page => deptype, ...): remove all influences, replace them +# with the arguments, and return a reference to a copy of the new influences. + sub influences { my $this=shift; $this->[1]={@_} if @_; @@ -2239,15 +2759,46 @@ sub influences { return \%i; } +# True if this result has the same influences whichever page it matches, +# For instance, whether bar matches backlink(foo) is influenced only by +# the set of links in foo, so its only influence is { foo => DEPEND_LINKS }, +# which does not mention bar anywhere. +# +# False if this result would have different influences when matching +# different pages. For instance, when testing whether link(foo) matches bar, +# { bar => DEPEND_LINKS } is an influence on that result, because changing +# bar's links could change the outcome; so its influences are not the same +# as when testing whether link(foo) matches baz. +# +# Static influences are one of the things that make pagespec_match_list +# more efficient than repeated calls to pagespec_match. + sub influences_static { return ! $_[0][1]->{""}; } +# Change the influences of $this to be the influences of "$this & $other" +# or "$this | $other". +# +# If both $this and $other are either successful or have influences, +# or this is an "or" operation, the result has all the influences from +# either of the arguments. It has dynamic influences if either argument +# has dynamic influences. +# +# If this is an "and" operation, and at least one argument is a +# FailReason with no influences, the result has no influences, and they +# are not dynamic. For instance, link(foo) matching bar is influenced +# by bar, but enabled(ddate) has no influences. Suppose ddate is disabled; +# then (link(foo) and enabled(ddate)) not matching bar is not influenced by +# bar, because it would be false however often you edit bar. + sub merge_influences { my $this=shift; my $other=shift; my $anded=shift; + # This "if" is odd because it needs to avoid negating $this + # or $other, which would alter the objects in-place. Be careful. if (! $anded || (($this || %{$this->[1]}) && ($other || %{$other->[1]}))) { foreach my $influence (keys %{$other->[1]}) { @@ -2260,6 +2811,8 @@ sub merge_influences { } } +# Change $this so it is not considered to be influenced by $torm. + sub remove_influence { my $this=shift; my $torm=shift; @@ -2277,15 +2830,23 @@ sub derel ($$) { my $path=shift; my $from=shift; - if ($path =~ m!^\./!) { - $from=~s#/?[^/]+$## if defined $from; - $path=~s#^\./##; - $path="$from/$path" if defined $from && length $from; + if ($path =~ m!^\.(/|$)!) { + if ($1) { + $from=~s#/?[^/]+$## if defined $from; + $path=~s#^\./##; + $path="$from/$path" if defined $from && length $from; + } + else { + $path = $from; + $path = "" unless defined $path; + } } return $path; } +my %glob_cache; + sub match_glob ($$;@) { my $page=shift; my $glob=shift; @@ -2293,8 +2854,13 @@ sub match_glob ($$;@) { $glob=derel($glob, $params{location}); - my $regexp=IkiWiki::glob2re($glob); - if ($page=~/^$regexp$/i) { + # Instead of converting the glob to a regex every time, + # cache the compiled regex to save time. + my $re=$glob_cache{$glob}; + unless (defined $re) { + $glob_cache{$glob} = $re = IkiWiki::glob2re($glob); + } + if ($page =~ $re) { if (! IkiWiki::isinternal($page) || $params{internal}) { return IkiWiki::SuccessReason->new("$glob matches $page"); } @@ -2308,7 +2874,22 @@ sub match_glob ($$;@) { } sub match_internal ($$;@) { - return match_glob($_[0], $_[1], @_, internal => 1) + return match_glob(shift, shift, @_, internal => 1) +} + +sub match_page ($$;@) { + my $page=shift; + my $match=match_glob($page, shift, @_); + if ($match) { + my $source=exists $IkiWiki::pagesources{$page} ? + $IkiWiki::pagesources{$page} : + $IkiWiki::delpagesources{$page}; + my $type=defined $source ? IkiWiki::pagetype($source) : undef; + if (! defined $type) { + return IkiWiki::FailReason->new("$page is not a page"); + } + } + return $match; } sub match_link ($$;@) { @@ -2329,18 +2910,20 @@ sub match_link ($$;@) { unless $links && @{$links}; my $bestlink = IkiWiki::bestlink($from, $link); foreach my $p (@{$links}) { + next unless (! defined $linktype || exists $IkiWiki::typedlinks{$page}{$linktype}{$p}); + if (length $bestlink) { - if ((!defined $linktype || exists $IkiWiki::typedlinks{$page}{$linktype}{$p}) && $bestlink eq IkiWiki::bestlink($page, $p)) { + if ($bestlink eq IkiWiki::bestlink($page, $p)) { return IkiWiki::SuccessReason->new("$page links to $link$qualifier", $page => $IkiWiki::DEPEND_LINKS, "" => 1) } } else { - if ((!defined $linktype || exists $IkiWiki::typedlinks{$page}{$linktype}{$p}) && match_glob($p, $link, %params)) { + if (match_glob($p, $link, %params)) { return IkiWiki::SuccessReason->new("$page links to page $p$qualifier, matching $link", $page => $IkiWiki::DEPEND_LINKS, "" => 1) } my ($p_rel)=$p=~/^\/?(.*)/; $link=~s/^\///; - if ((!defined $linktype || exists $IkiWiki::typedlinks{$page}{$linktype}{$p_rel}) && match_glob($p_rel, $link, %params)) { + if (match_glob($p_rel, $link, %params)) { return IkiWiki::SuccessReason->new("$page links to page $p_rel$qualifier, matching $link", $page => $IkiWiki::DEPEND_LINKS, "" => 1) } } @@ -2349,8 +2932,14 @@ sub match_link ($$;@) { } sub match_backlink ($$;@) { - my $ret=match_link($_[1], $_[0], @_); - $ret->influences($_[1] => $IkiWiki::DEPEND_LINKS); + my $page=shift; + my $testpage=shift; + my %params=@_; + if ($testpage eq '.') { + $testpage = $params{'location'} + } + my $ret=match_link($testpage, $page, @_); + $ret->influences($testpage => $IkiWiki::DEPEND_LINKS); return $ret; } @@ -2395,7 +2984,12 @@ sub match_created_after ($$;@) { } sub match_creation_day ($$;@) { - if ((gmtime($IkiWiki::pagectime{shift()}))[3] == shift) { + my $page=shift; + my $d=shift; + if ($d !~ /^\d+$/) { + return IkiWiki::ErrorReason->new("invalid day $d"); + } + if ((localtime($IkiWiki::pagectime{$page}))[3] == $d) { return IkiWiki::SuccessReason->new('creation_day matched'); } else { @@ -2404,7 +2998,12 @@ sub match_creation_day ($$;@) { } sub match_creation_month ($$;@) { - if ((gmtime($IkiWiki::pagectime{shift()}))[4] + 1 == shift) { + my $page=shift; + my $m=shift; + if ($m !~ /^\d+$/) { + return IkiWiki::ErrorReason->new("invalid month $m"); + } + if ((localtime($IkiWiki::pagectime{$page}))[4] + 1 == $m) { return IkiWiki::SuccessReason->new('creation_month matched'); } else { @@ -2413,7 +3012,12 @@ sub match_creation_month ($$;@) { } sub match_creation_year ($$;@) { - if ((gmtime($IkiWiki::pagectime{shift()}))[5] + 1900 == shift) { + my $page=shift; + my $y=shift; + if ($y !~ /^\d+$/) { + return IkiWiki::ErrorReason->new("invalid year $y"); + } + if ((localtime($IkiWiki::pagectime{$page}))[5] + 1900 == $y) { return IkiWiki::SuccessReason->new('creation_year matched'); } else { @@ -2426,13 +3030,13 @@ sub match_user ($$;@) { my $user=shift; my %params=@_; - my $regexp=IkiWiki::glob2re($user); - if (! exists $params{user}) { return IkiWiki::ErrorReason->new("no user specified"); } - if (defined $params{user} && $params{user}=~/^$regexp$/i) { + my $regexp=IkiWiki::glob2re($user); + + if (defined $params{user} && $params{user}=~$regexp) { return IkiWiki::SuccessReason->new("user is $user"); } elsif (! defined $params{user}) { @@ -2471,8 +3075,10 @@ sub match_ip ($$;@) { if (! exists $params{ip}) { return IkiWiki::ErrorReason->new("no IP specified"); } + + my $regexp=IkiWiki::glob2re(lc $ip); - if (defined $params{ip} && lc $params{ip} eq lc $ip) { + if (defined $params{ip} && lc $params{ip}=~$regexp) { return IkiWiki::SuccessReason->new("IP is $ip"); } else { @@ -2495,6 +3101,7 @@ sub cmp_title { IkiWiki::pagetitle(IkiWiki::basename($b)) } +sub cmp_path { IkiWiki::pagetitle($a) cmp IkiWiki::pagetitle($b) } sub cmp_mtime { $IkiWiki::pagemtime{$b} <=> $IkiWiki::pagemtime{$a} } sub cmp_age { $IkiWiki::pagectime{$b} <=> $IkiWiki::pagectime{$a} }