* Fixed a bug with previews of subpages having broken links to top-level
[ikiwiki] / IkiWiki.pm
1 #!/usr/bin/perl
2
3 package IkiWiki;
4 use warnings;
5 use strict;
6 use Encode;
7 use HTML::Entities;
8 use open qw{:utf8 :std};
9
10 # Optimisation.
11 use Memoize;
12 memoize("abs2rel");
13 memoize("pagespec_translate");
14
15 use vars qw{%config %links %oldlinks %oldpagemtime %pagectime %pagecase
16             %renderedfiles %pagesources %depends %hooks %forcerebuild};
17
18 sub defaultconfig () { #{{{
19         wiki_file_prune_regexp => qr{((^|/).svn/|\.\.|^\.|\/\.|\.x?html?$|\.rss$)},
20         wiki_link_regexp => qr/\[\[(?:([^\]\|]+)\|)?([^\s\]]+)\]\]/,
21         wiki_processor_regexp => qr/\[\[(\w+)\s+([^\]]*)\]\]/,
22         wiki_file_regexp => qr/(^[-[:alnum:]_.:\/+]+$)/,
23         verbose => 0,
24         syslog => 0,
25         wikiname => "wiki",
26         default_pageext => "mdwn",
27         cgi => 0,
28         rcs => 'svn',
29         notify => 0,
30         url => '',
31         cgiurl => '',
32         historyurl => '',
33         diffurl => '',
34         anonok => 0,
35         rss => 0,
36         discussion => 1,
37         rebuild => 0,
38         refresh => 0,
39         getctime => 0,
40         w3mmode => 0,
41         wrapper => undef,
42         wrappermode => undef,
43         svnrepo => undef,
44         svnpath => "trunk",
45         srcdir => undef,
46         destdir => undef,
47         pingurl => [],
48         templatedir => "/usr/share/ikiwiki/templates",
49         underlaydir => "/usr/share/ikiwiki/basewiki",
50         setup => undef,
51         adminuser => undef,
52         adminemail => undef,
53         plugin => [qw{mdwn inline htmlscrubber}],
54         timeformat => '%c',
55         locale => undef,
56 } #}}}
57    
58 sub checkconfig () { #{{{
59         # locale stuff; avoid LC_ALL since it overrides everything
60         if (defined $ENV{LC_ALL}) {
61                 $ENV{LANG} = $ENV{LC_ALL};
62                 delete $ENV{LC_ALL};
63         }
64         if (defined $config{locale}) {
65                 eval q{use POSIX};
66                 $ENV{LANG} = $config{locale}
67                         if POSIX::setlocale(&POSIX::LC_TIME, $config{locale});
68         }
69
70         if ($config{w3mmode}) {
71                 eval q{use Cwd q{abs_path}};
72                 $config{srcdir}=possibly_foolish_untaint(abs_path($config{srcdir}));
73                 $config{destdir}=possibly_foolish_untaint(abs_path($config{destdir}));
74                 $config{cgiurl}="file:///\$LIB/ikiwiki-w3m.cgi/".$config{cgiurl}
75                         unless $config{cgiurl} =~ m!file:///!;
76                 $config{url}="file://".$config{destdir};
77         }
78
79         if ($config{cgi} && ! length $config{url}) {
80                 error("Must specify url to wiki with --url when using --cgi\n");
81         }
82         if ($config{rss} && ! length $config{url}) {
83                 error("Must specify url to wiki with --url when using --rss\n");
84         }
85         
86         $config{wikistatedir}="$config{srcdir}/.ikiwiki"
87                 unless exists $config{wikistatedir};
88         
89         if ($config{rcs}) {
90                 eval qq{require IkiWiki::Rcs::$config{rcs}};
91                 if ($@) {
92                         error("Failed to load RCS module IkiWiki::Rcs::$config{rcs}: $@");
93                 }
94         }
95         else {
96                 require IkiWiki::Rcs::Stub;
97         }
98
99         run_hooks(checkconfig => sub { shift->() });
100 } #}}}
101
102 sub loadplugins () { #{{{
103         foreach my $plugin (@{$config{plugin}}) {
104                 my $mod="IkiWiki::Plugin::".possibly_foolish_untaint($plugin);
105                 eval qq{use $mod};
106                 if ($@) {
107                         error("Failed to load plugin $mod: $@");
108                 }
109         }
110         run_hooks(getopt => sub { shift->() });
111         if (grep /^-/, @ARGV) {
112                 print STDERR "Unknown option: $_\n"
113                         foreach grep /^-/, @ARGV;
114                 usage();
115         }
116 } #}}}
117
118 sub error ($) { #{{{
119         if ($config{cgi}) {
120                 print "Content-type: text/html\n\n";
121                 print misctemplate("Error", "<p>Error: @_</p>");
122         }
123         log_message(error => @_);
124         exit(1);
125 } #}}}
126
127 sub debug ($) { #{{{
128         return unless $config{verbose};
129         log_message(debug => @_);
130 } #}}}
131
132 my $log_open=0;
133 sub log_message ($$) { #{{{
134         my $type=shift;
135
136         if ($config{syslog}) {
137                 require Sys::Syslog;
138                 unless ($log_open) {
139                         Sys::Syslog::setlogsock('unix');
140                         Sys::Syslog::openlog('ikiwiki', '', 'user');
141                         $log_open=1;
142                 }
143                 eval {
144                         Sys::Syslog::syslog($type, join(" ", @_));
145                 }
146         }
147         elsif (! $config{cgi}) {
148                 print "@_\n";
149         }
150         else {
151                 print STDERR "@_\n";
152         }
153 } #}}}
154
155 sub possibly_foolish_untaint ($) { #{{{
156         my $tainted=shift;
157         my ($untainted)=$tainted=~/(.*)/;
158         return $untainted;
159 } #}}}
160
161 sub basename ($) { #{{{
162         my $file=shift;
163
164         $file=~s!.*/+!!;
165         return $file;
166 } #}}}
167
168 sub dirname ($) { #{{{
169         my $file=shift;
170
171         $file=~s!/*[^/]+$!!;
172         return $file;
173 } #}}}
174
175 sub pagetype ($) { #{{{
176         my $page=shift;
177         
178         if ($page =~ /\.([^.]+)$/) {
179                 return $1 if exists $hooks{htmlize}{$1};
180         }
181         return undef;
182 } #}}}
183
184 sub pagename ($) { #{{{
185         my $file=shift;
186
187         my $type=pagetype($file);
188         my $page=$file;
189         $page=~s/\Q.$type\E*$// if defined $type;
190         return $page;
191 } #}}}
192
193 sub htmlpage ($) { #{{{
194         my $page=shift;
195
196         return $page.".html";
197 } #}}}
198
199 sub srcfile ($) { #{{{
200         my $file=shift;
201
202         return "$config{srcdir}/$file" if -e "$config{srcdir}/$file";
203         return "$config{underlaydir}/$file" if -e "$config{underlaydir}/$file";
204         error("internal error: $file cannot be found");
205 } #}}}
206
207 sub readfile ($;$) { #{{{
208         my $file=shift;
209         my $binary=shift;
210
211         if (-l $file) {
212                 error("cannot read a symlink ($file)");
213         }
214         
215         local $/=undef;
216         open (IN, $file) || error("failed to read $file: $!");
217         binmode(IN) if ($binary);
218         my $ret=<IN>;
219         close IN;
220         return $ret;
221 } #}}}
222
223 sub writefile ($$$;$) { #{{{
224         my $file=shift; # can include subdirs
225         my $destdir=shift; # directory to put file in
226         my $content=shift;
227         my $binary=shift;
228         
229         my $test=$file;
230         while (length $test) {
231                 if (-l "$destdir/$test") {
232                         error("cannot write to a symlink ($test)");
233                 }
234                 $test=dirname($test);
235         }
236
237         my $dir=dirname("$destdir/$file");
238         if (! -d $dir) {
239                 my $d="";
240                 foreach my $s (split(m!/+!, $dir)) {
241                         $d.="$s/";
242                         if (! -d $d) {
243                                 mkdir($d) || error("failed to create directory $d: $!");
244                         }
245                 }
246         }
247         
248         open (OUT, ">$destdir/$file") || error("failed to write $destdir/$file: $!");
249         binmode(OUT) if ($binary);
250         print OUT $content;
251         close OUT;
252 } #}}}
253
254 sub bestlink ($$) { #{{{
255         # Given a page and the text of a link on the page, determine which
256         # existing page that link best points to. Prefers pages under a
257         # subdirectory with the same name as the source page, failing that
258         # goes down the directory tree to the base looking for matching
259         # pages.
260         my $page=shift;
261         my $link=shift;
262         
263         my $cwd=$page;
264         do {
265                 my $l=$cwd;
266                 $l.="/" if length $l;
267                 $l.=$link;
268
269                 if (exists $links{$l}) {
270                         return $l;
271                 }
272                 elsif (exists $pagecase{lc $l}) {
273                         return $pagecase{lc $l};
274                 }
275         } while $cwd=~s!/?[^/]+$!!;
276
277         #print STDERR "warning: page $page, broken link: $link\n";
278         return "";
279 } #}}}
280
281 sub isinlinableimage ($) { #{{{
282         my $file=shift;
283         
284         $file=~/\.(png|gif|jpg|jpeg)$/i;
285 } #}}}
286
287 sub pagetitle ($) { #{{{
288         my $page=shift;
289         $page=~s/__(\d+)__/&#$1;/g;
290         $page=~y/_/ /;
291         return $page;
292 } #}}}
293
294 sub titlepage ($) { #{{{
295         my $title=shift;
296         $title=~y/ /_/;
297         $title=~s/([^-[:alnum:]_:+\/.])/"__".ord($1)."__"/eg;
298         return $title;
299 } #}}}
300
301 sub cgiurl (@) { #{{{
302         my %params=@_;
303
304         return $config{cgiurl}."?".join("&amp;", map "$_=$params{$_}", keys %params);
305 } #}}}
306
307 sub baseurl (;$) { #{{{
308         my $page=shift;
309
310         return "$config{url}/" if ! defined $page;
311         
312         $page=~s/[^\/]+$//;
313         $page=~s/[^\/]+\//..\//g;
314         return $page;
315 } #}}}
316
317 sub abs2rel ($$) { #{{{
318         # Work around very innefficient behavior in File::Spec if abs2rel
319         # is passed two relative paths. It's much faster if paths are
320         # absolute!
321         my $path="/".shift;
322         my $base="/".shift;
323
324         require File::Spec;
325         my $ret=File::Spec->abs2rel($path, $base);
326         $ret=~s/^// if defined $ret;
327         return $ret;
328 } #}}}
329
330 sub htmllink ($$$;$$$) { #{{{
331         my $lpage=shift; # the page doing the linking
332         my $page=shift; # the page that will contain the link (different for inline)
333         my $link=shift;
334         my $noimageinline=shift; # don't turn links into inline html images
335         my $forcesubpage=shift; # force a link to a subpage
336         my $linktext=shift; # set to force the link text to something
337
338         my $bestlink;
339         if (! $forcesubpage) {
340                 $bestlink=bestlink($lpage, $link);
341         }
342         else {
343                 $bestlink="$lpage/".lc($link);
344         }
345
346         $linktext=pagetitle(basename($link)) unless defined $linktext;
347         
348         return "<span class=\"selflink\">$linktext</span>"
349                 if length $bestlink && $page eq $bestlink;
350         
351         # TODO BUG: %renderedfiles may not have it, if the linked to page
352         # was also added and isn't yet rendered! Note that this bug is
353         # masked by the bug that makes all new files be rendered twice.
354         if (! grep { $_ eq $bestlink } values %renderedfiles) {
355                 $bestlink=htmlpage($bestlink);
356         }
357         if (! grep { $_ eq $bestlink } values %renderedfiles) {
358                 return "<span><a href=\"".
359                         cgiurl(do => "create", page => lc($link), from => $page).
360                         "\">?</a>$linktext</span>"
361         }
362         
363         $bestlink=abs2rel($bestlink, dirname($page));
364         
365         if (! $noimageinline && isinlinableimage($bestlink)) {
366                 return "<img src=\"$bestlink\" alt=\"$linktext\" />";
367         }
368         return "<a href=\"$bestlink\">$linktext</a>";
369 } #}}}
370
371 sub indexlink () { #{{{
372         return "<a href=\"$config{url}\">$config{wikiname}</a>";
373 } #}}}
374
375 sub lockwiki () { #{{{
376         # Take an exclusive lock on the wiki to prevent multiple concurrent
377         # run issues. The lock will be dropped on program exit.
378         if (! -d $config{wikistatedir}) {
379                 mkdir($config{wikistatedir});
380         }
381         open(WIKILOCK, ">$config{wikistatedir}/lockfile") ||
382                 error ("cannot write to $config{wikistatedir}/lockfile: $!");
383         if (! flock(WIKILOCK, 2 | 4)) {
384                 debug("wiki seems to be locked, waiting for lock");
385                 my $wait=600; # arbitrary, but don't hang forever to 
386                               # prevent process pileup
387                 for (1..600) {
388                         return if flock(WIKILOCK, 2 | 4);
389                         sleep 1;
390                 }
391                 error("wiki is locked; waited $wait seconds without lock being freed (possible stuck process or stale lock?)");
392         }
393 } #}}}
394
395 sub unlockwiki () { #{{{
396         close WIKILOCK;
397 } #}}}
398
399 sub loadindex () { #{{{
400         open (IN, "$config{wikistatedir}/index") || return;
401         while (<IN>) {
402                 $_=possibly_foolish_untaint($_);
403                 chomp;
404                 my %items;
405                 $items{link}=[];
406                 foreach my $i (split(/ /, $_)) {
407                         my ($item, $val)=split(/=/, $i, 2);
408                         push @{$items{$item}}, decode_entities($val);
409                 }
410
411                 next unless exists $items{src}; # skip bad lines for now
412
413                 my $page=pagename($items{src}[0]);
414                 if (! $config{rebuild}) {
415                         $pagesources{$page}=$items{src}[0];
416                         $oldpagemtime{$page}=$items{mtime}[0];
417                         $oldlinks{$page}=[@{$items{link}}];
418                         $links{$page}=[@{$items{link}}];
419                         $depends{$page}=$items{depends}[0] if exists $items{depends};
420                         $renderedfiles{$page}=$items{dest}[0];
421                         $pagecase{lc $page}=$page;
422                 }
423                 $pagectime{$page}=$items{ctime}[0];
424         }
425         close IN;
426 } #}}}
427
428 sub saveindex () { #{{{
429         run_hooks(savestate => sub { shift->() });
430
431         if (! -d $config{wikistatedir}) {
432                 mkdir($config{wikistatedir});
433         }
434         open (OUT, ">$config{wikistatedir}/index") || 
435                 error("cannot write to $config{wikistatedir}/index: $!");
436         foreach my $page (keys %oldpagemtime) {
437                 next unless $oldpagemtime{$page};
438                 my $line="mtime=$oldpagemtime{$page} ".
439                         "ctime=$pagectime{$page} ".
440                         "src=$pagesources{$page} ".
441                         "dest=$renderedfiles{$page}";
442                 $line.=" link=$_" foreach @{$links{$page}};
443                 if (exists $depends{$page}) {
444                         $line.=" depends=".encode_entities($depends{$page}, " \t\n");
445                 }
446                 print OUT $line."\n";
447         }
448         close OUT;
449 } #}}}
450
451 sub template_params (@) { #{{{
452         my $filename=shift;
453         
454         require HTML::Template;
455         return filter => sub {
456                         my $text_ref = shift;
457                         $$text_ref=&Encode::decode_utf8($$text_ref);
458                 },
459                 filename => "$config{templatedir}/$filename",
460                 loop_context_vars => 1,
461                 die_on_bad_params => 0,
462                 @_;
463 } #}}}
464
465 sub template ($;@) { #{{{
466         HTML::Template->new(template_params(@_));
467 } #}}}
468
469 sub misctemplate ($$) { #{{{
470         my $title=shift;
471         my $pagebody=shift;
472         
473         my $template=template("misc.tmpl");
474         $template->param(
475                 title => $title,
476                 indexlink => indexlink(),
477                 wikiname => $config{wikiname},
478                 pagebody => $pagebody,
479                 baseurl => baseurl(),
480         );
481         return $template->output;
482 }#}}}
483
484 sub hook (@) { # {{{
485         my %param=@_;
486         
487         if (! exists $param{type} || ! ref $param{call} || ! exists $param{id}) {
488                 error "hook requires type, call, and id parameters";
489         }
490         
491         $hooks{$param{type}}{$param{id}}=\%param;
492 } # }}}
493
494 sub run_hooks ($$) { # {{{
495         # Calls the given sub for each hook of the given type,
496         # passing it the hook function to call.
497         my $type=shift;
498         my $sub=shift;
499
500         if (exists $hooks{$type}) {
501                 foreach my $id (keys %{$hooks{$type}}) {
502                         $sub->($hooks{$type}{$id}{call});
503                 }
504         }
505 } #}}}
506
507 sub globlist_to_pagespec ($) { #{{{
508         my @globlist=split(' ', shift);
509
510         my (@spec, @skip);
511         foreach my $glob (@globlist) {
512                 if ($glob=~/^!(.*)/) {
513                         push @skip, $glob;
514                 }
515                 else {
516                         push @spec, $glob;
517                 }
518         }
519
520         my $spec=join(" or ", @spec);
521         if (@skip) {
522                 my $skip=join(" and ", @skip);
523                 if (length $spec) {
524                         $spec="$skip and ($spec)";
525                 }
526                 else {
527                         $spec=$skip;
528                 }
529         }
530         return $spec;
531 } #}}}
532
533 sub is_globlist ($) { #{{{
534         my $s=shift;
535         $s=~/[^\s]+\s+([^\s]+)/ && $1 ne "and" && $1 ne "or";
536 } #}}}
537
538 sub safequote ($) { #{{{
539         my $s=shift;
540         $s=~s/[{}]//g;
541         return "q{$s}";
542 } #}}}
543
544 sub pagespec_merge ($$) { #{{{
545         my $a=shift;
546         my $b=shift;
547
548         # Support for old-style GlobLists.
549         if (is_globlist($a)) {
550                 $a=globlist_to_pagespec($a);
551         }
552         if (is_globlist($b)) {
553                 $b=globlist_to_pagespec($b);
554         }
555
556         return "($a) or ($b)";
557 } #}}}
558
559 sub pagespec_translate ($) { #{{{
560         # This assumes that $page is in scope in the function
561         # that evalulates the translated pagespec code.
562         my $spec=shift;
563
564         # Support for old-style GlobLists.
565         if (is_globlist($spec)) {
566                 $spec=globlist_to_pagespec($spec);
567         }
568
569         # Convert spec to perl code.
570         my $code="";
571         while ($spec=~m/\s*(\!|\(|\)|\w+\([^\)]+\)|[^\s()]+)\s*/ig) {
572                 my $word=$1;
573                 if (lc $word eq "and") {
574                         $code.=" &&";
575                 }
576                 elsif (lc $word eq "or") {
577                         $code.=" ||";
578                 }
579                 elsif ($word eq "(" || $word eq ")" || $word eq "!") {
580                         $code.=" ".$word;
581                 }
582                 elsif ($word =~ /^(link|backlink|created_before|created_after|creation_month|creation_year|creation_day)\((.+)\)$/) {
583                         $code.=" match_$1(\$page, ".safequote($2).")";
584                 }
585                 else {
586                         $code.=" match_glob(\$page, ".safequote($word).")";
587                 }
588         }
589
590         return $code;
591 } #}}}
592
593 sub pagespec_match ($$) { #{{{
594         my $page=shift;
595         my $spec=shift;
596
597         return eval pagespec_translate($spec);
598 } #}}}
599
600 sub match_glob ($$) { #{{{
601         my $page=shift;
602         my $glob=shift;
603
604         # turn glob into safe regexp
605         $glob=quotemeta($glob);
606         $glob=~s/\\\*/.*/g;
607         $glob=~s/\\\?/./g;
608
609         return $page=~/^$glob$/i;
610 } #}}}
611
612 sub match_link ($$) { #{{{
613         my $page=shift;
614         my $link=lc(shift);
615
616         my $links = $links{$page} or return undef;
617         foreach my $p (@$links) {
618                 return 1 if lc $p eq $link;
619         }
620         return 0;
621 } #}}}
622
623 sub match_backlink ($$) { #{{{
624         match_link(pop, pop);
625 } #}}}
626
627 sub match_created_before ($$) { #{{{
628         my $page=shift;
629         my $testpage=shift;
630
631         if (exists $pagectime{$testpage}) {
632                 return $pagectime{$page} < $pagectime{$testpage};
633         }
634         else {
635                 return 0;
636         }
637 } #}}}
638
639 sub match_created_after ($$) { #{{{
640         my $page=shift;
641         my $testpage=shift;
642
643         if (exists $pagectime{$testpage}) {
644                 return $pagectime{$page} > $pagectime{$testpage};
645         }
646         else {
647                 return 0;
648         }
649 } #}}}
650
651 sub match_creation_day ($$) { #{{{
652         return ((gmtime($pagectime{shift()}))[3] == shift);
653 } #}}}
654
655 sub match_creation_month ($$) { #{{{
656         return ((gmtime($pagectime{shift()}))[4] + 1 == shift);
657 } #}}}
658
659 sub match_creation_year ($$) { #{{{
660         return ((gmtime($pagectime{shift()}))[5] + 1900 == shift);
661 } #}}}
662
663 1