web commit by joey: heh heh heh
[ikiwiki.git] / 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 styleurl (;$) { #{{{
308         my $page=shift;
309
310         return "$config{url}/style.css" if ! defined $page;
311         
312         $page=~s/[^\/]+$//;
313         $page=~s/[^\/]+\//..\//g;
314         return $page."style.css";
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                 styleurl => styleurl(),
480                 baseurl => "$config{url}/",
481         );
482         return $template->output;
483 }#}}}
484
485 sub hook (@) { # {{{
486         my %param=@_;
487         
488         if (! exists $param{type} || ! ref $param{call} || ! exists $param{id}) {
489                 error "hook requires type, call, and id parameters";
490         }
491         
492         $hooks{$param{type}}{$param{id}}=\%param;
493 } # }}}
494
495 sub run_hooks ($$) { # {{{
496         # Calls the given sub for each hook of the given type,
497         # passing it the hook function to call.
498         my $type=shift;
499         my $sub=shift;
500
501         if (exists $hooks{$type}) {
502                 foreach my $id (keys %{$hooks{$type}}) {
503                         $sub->($hooks{$type}{$id}{call});
504                 }
505         }
506 } #}}}
507
508 sub globlist_to_pagespec ($) { #{{{
509         my @globlist=split(' ', shift);
510
511         my (@spec, @skip);
512         foreach my $glob (@globlist) {
513                 if ($glob=~/^!(.*)/) {
514                         push @skip, $glob;
515                 }
516                 else {
517                         push @spec, $glob;
518                 }
519         }
520
521         my $spec=join(" or ", @spec);
522         if (@skip) {
523                 my $skip=join(" and ", @skip);
524                 if (length $spec) {
525                         $spec="$skip and ($spec)";
526                 }
527                 else {
528                         $spec=$skip;
529                 }
530         }
531         return $spec;
532 } #}}}
533
534 sub is_globlist ($) { #{{{
535         my $s=shift;
536         $s=~/[^\s]+\s+([^\s]+)/ && $1 ne "and" && $1 ne "or";
537 } #}}}
538
539 sub safequote ($) { #{{{
540         my $s=shift;
541         $s=~s/[{}]//g;
542         return "q{$s}";
543 } #}}}
544
545 sub pagespec_merge ($$) { #{{{
546         my $a=shift;
547         my $b=shift;
548
549         # Support for old-style GlobLists.
550         if (is_globlist($a)) {
551                 $a=globlist_to_pagespec($a);
552         }
553         if (is_globlist($b)) {
554                 $b=globlist_to_pagespec($b);
555         }
556
557         return "($a) or ($b)";
558 } #}}}
559
560 sub pagespec_translate ($) { #{{{
561         # This assumes that $page is in scope in the function
562         # that evalulates the translated pagespec code.
563         my $spec=shift;
564
565         # Support for old-style GlobLists.
566         if (is_globlist($spec)) {
567                 $spec=globlist_to_pagespec($spec);
568         }
569
570         # Convert spec to perl code.
571         my $code="";
572         while ($spec=~m/\s*(\!|\(|\)|\w+\([^\)]+\)|[^\s()]+)\s*/ig) {
573                 my $word=$1;
574                 if (lc $word eq "and") {
575                         $code.=" &&";
576                 }
577                 elsif (lc $word eq "or") {
578                         $code.=" ||";
579                 }
580                 elsif ($word eq "(" || $word eq ")" || $word eq "!") {
581                         $code.=" ".$word;
582                 }
583                 elsif ($word =~ /^(link|backlink|created_before|created_after|creation_month|creation_year|creation_day)\((.+)\)$/) {
584                         $code.=" match_$1(\$page, ".safequote($2).")";
585                 }
586                 else {
587                         $code.=" match_glob(\$page, ".safequote($word).")";
588                 }
589         }
590
591         return $code;
592 } #}}}
593
594 sub pagespec_match ($$) { #{{{
595         my $page=shift;
596         my $spec=shift;
597
598         return eval pagespec_translate($spec);
599 } #}}}
600
601 sub match_glob ($$) { #{{{
602         my $page=shift;
603         my $glob=shift;
604
605         # turn glob into safe regexp
606         $glob=quotemeta($glob);
607         $glob=~s/\\\*/.*/g;
608         $glob=~s/\\\?/./g;
609
610         return $page=~/^$glob$/i;
611 } #}}}
612
613 sub match_link ($$) { #{{{
614         my $page=shift;
615         my $link=lc(shift);
616
617         my $links = $links{$page} or return undef;
618         foreach my $p (@$links) {
619                 return 1 if lc $p eq $link;
620         }
621         return 0;
622 } #}}}
623
624 sub match_backlink ($$) { #{{{
625         match_link(pop, pop);
626 } #}}}
627
628 sub match_created_before ($$) { #{{{
629         my $page=shift;
630         my $testpage=shift;
631
632         if (exists $pagectime{$testpage}) {
633                 return $pagectime{$page} < $pagectime{$testpage};
634         }
635         else {
636                 return 0;
637         }
638 } #}}}
639
640 sub match_created_after ($$) { #{{{
641         my $page=shift;
642         my $testpage=shift;
643
644         if (exists $pagectime{$testpage}) {
645                 return $pagectime{$page} > $pagectime{$testpage};
646         }
647         else {
648                 return 0;
649         }
650 } #}}}
651
652 sub match_creation_day ($$) { #{{{
653         return ((gmtime($pagectime{shift()}))[3] == shift);
654 } #}}}
655
656 sub match_creation_month ($$) { #{{{
657         return ((gmtime($pagectime{shift()}))[4] + 1 == shift);
658 } #}}}
659
660 sub match_creation_year ($$) { #{{{
661         return ((gmtime($pagectime{shift()}))[5] + 1900 == shift);
662 } #}}}
663
664 1