Add configuration to restrict the formats allowed for comments
[ikiwiki.git] / IkiWiki / Plugin / comments.pm
1 #!/usr/bin/perl
2 # Copyright © 2006-2008 Joey Hess <joey@ikiwiki.info>
3 # Copyright © 2008 Simon McVittie <http://smcv.pseudorandom.co.uk/>
4 # Licensed under the GNU GPL, version 2, or any later version published by the
5 # Free Software Foundation
6 package IkiWiki::Plugin::comments;
7
8 use warnings;
9 use strict;
10 use IkiWiki 3.00;
11 use Encode;
12
13 use constant PREVIEW => "Preview";
14 use constant POST_COMMENT => "Post comment";
15 use constant CANCEL => "Cancel";
16
17 my $postcomment;
18 my %commentstate;
19
20 sub import {
21         hook(type => "checkconfig", id => 'comments',  call => \&checkconfig);
22         hook(type => "getsetup", id => 'comments',  call => \&getsetup);
23         hook(type => "preprocess", id => 'comment', call => \&preprocess,
24                 scan => 1);
25         hook(type => "preprocess", id => 'commentmoderation', call => \&preprocess_moderation);
26         # here for backwards compatability with old comments
27         hook(type => "preprocess", id => '_comment', call => \&preprocess);
28         hook(type => "sessioncgi", id => 'comment', call => \&sessioncgi);
29         hook(type => "htmlize", id => "_comment", call => \&htmlize);
30         hook(type => "htmlize", id => "_comment_pending",
31                 call => \&htmlize_pending);
32         hook(type => "pagetemplate", id => "comments", call => \&pagetemplate);
33         hook(type => "formbuilder_setup", id => "comments",
34                 call => \&formbuilder_setup);
35         # Load goto to fix up user page links for logged-in commenters
36         IkiWiki::loadplugin("goto");
37         IkiWiki::loadplugin("inline");
38 }
39
40 sub getsetup () {
41         return
42                 plugin => {
43                         safe => 1,
44                         rebuild => 1,
45                         section => "web",
46                 },
47                 comments_pagespec => {
48                         type => 'pagespec',
49                         example => 'blog/* and !*/Discussion',
50                         description => 'PageSpec of pages where comments are allowed',
51                         link => 'ikiwiki/PageSpec',
52                         safe => 1,
53                         rebuild => 1,
54                 },
55                 comments_closed_pagespec => {
56                         type => 'pagespec',
57                         example => 'blog/controversial or blog/flamewar',
58                         description => 'PageSpec of pages where posting new comments is not allowed',
59                         link => 'ikiwiki/PageSpec',
60                         safe => 1,
61                         rebuild => 1,
62                 },
63                 comments_pagename => {
64                         type => 'string',
65                         default => 'comment_',
66                         description => 'Base name for comments, e.g. "comment_" for pages like "sandbox/comment_12"',
67                         safe => 0, # manual page moving required
68                         rebuild => undef,
69                 },
70                 comments_allowdirectives => {
71                         type => 'boolean',
72                         example => 0,
73                         description => 'Interpret directives in comments?',
74                         safe => 1,
75                         rebuild => 0,
76                 },
77                 comments_allowauthor => {
78                         type => 'boolean',
79                         example => 0,
80                         description => 'Allow anonymous commenters to set an author name?',
81                         safe => 1,
82                         rebuild => 0,
83                 },
84                 comments_commit => {
85                         type => 'boolean',
86                         example => 1,
87                         description => 'commit comments to the VCS',
88                         # old uncommitted comments are likely to cause
89                         # confusion if this is changed
90                         safe => 0,
91                         rebuild => 0,
92                 },
93                 comments_allowformats => {
94                         type => 'string',
95                         default => '',
96                         example => 'mdwn txt',
97                         description => 'Restrict formats for comments to (no restriction if empty)',
98                         safe => 1,
99                         rebuild => 0,
100                 },
101
102 }
103
104 sub checkconfig () {
105         $config{comments_commit} = 1
106                 unless defined $config{comments_commit};
107         $config{comments_pagespec} = ''
108                 unless defined $config{comments_pagespec};
109         $config{comments_closed_pagespec} = ''
110                 unless defined $config{comments_closed_pagespec};
111         $config{comments_pagename} = 'comment_'
112                 unless defined $config{comments_pagename};
113         $config{comments_allowformats} = ''
114                 unless defined $config{comments_allowformats};
115 }
116
117 sub htmlize {
118         my %params = @_;
119         return $params{content};
120 }
121
122 sub htmlize_pending {
123         my %params = @_;
124         return sprintf(gettext("this comment needs %s"),
125                 '<a href="'.
126                 IkiWiki::cgiurl(do => "commentmoderation").'">'.
127                 gettext("moderation").'</a>');
128 }
129
130 # FIXME: copied verbatim from meta
131 sub safeurl ($) {
132         my $url=shift;
133         if (exists $IkiWiki::Plugin::htmlscrubber::{safe_url_regexp} &&
134             defined $IkiWiki::Plugin::htmlscrubber::safe_url_regexp) {
135                 return $url=~/$IkiWiki::Plugin::htmlscrubber::safe_url_regexp/;
136         }
137         else {
138                 return 1;
139         }
140 }
141
142 sub isallowed ($) {
143     my $format = shift;
144     return ! $config{comments_allowformats} || $config{comments_allowformats} =~ /\b$format\b/;
145 }
146
147 sub preprocess {
148         my %params = @_;
149         my $page = $params{page};
150
151         my $format = $params{format};
152         if (defined $format && (! exists $IkiWiki::hooks{htmlize}{$format} ||
153                                 ! isallowed($format))) {
154                 error(sprintf(gettext("unsupported page format %s"), $format));
155         }
156
157         my $content = $params{content};
158         if (! defined $content) {
159                 error(gettext("comment must have content"));
160         }
161         $content =~ s/\\"/"/g;
162
163         if (defined wantarray) {
164                 if ($config{comments_allowdirectives}) {
165                         $content = IkiWiki::preprocess($page, $params{destpage},
166                                 $content);
167                 }
168
169                 # no need to bother with htmlize if it's just HTML
170                 $content = IkiWiki::htmlize($page, $params{destpage}, $format, $content)
171                         if defined $format;
172
173                 IkiWiki::run_hooks(sanitize => sub {
174                         $content = shift->(
175                                 page => $page,
176                                 destpage => $params{destpage},
177                                 content => $content,
178                         );
179                 });
180         }
181         else {
182                 IkiWiki::preprocess($page, $params{destpage}, $content, 1);
183         }
184
185         # set metadata, possibly overriding [[!meta]] directives from the
186         # comment itself
187
188         my $commentuser;
189         my $commentip;
190         my $commentauthor;
191         my $commentauthorurl;
192         my $commentopenid;
193         if (defined $params{username}) {
194                 $commentuser = $params{username};
195
196                 my $oiduser = eval { IkiWiki::openiduser($commentuser) };
197
198                 if (defined $oiduser) {
199                         # looks like an OpenID
200                         $commentauthorurl = $commentuser;
201                         $commentauthor = (defined $params{nickname} && length $params{nickname}) ? $params{nickname} : $oiduser;
202                         $commentopenid = $commentuser;
203                 }
204                 else {
205                         $commentauthorurl = IkiWiki::cgiurl(
206                                 do => 'goto',
207                                 page => IkiWiki::userpage($commentuser)
208                         );
209
210                         $commentauthor = $commentuser;
211                 }
212         }
213         else {
214                 if (defined $params{ip}) {
215                         $commentip = $params{ip};
216                 }
217                 $commentauthor = gettext("Anonymous");
218         }
219
220         $commentstate{$page}{commentuser} = $commentuser;
221         $commentstate{$page}{commentopenid} = $commentopenid;
222         $commentstate{$page}{commentip} = $commentip;
223         $commentstate{$page}{commentauthor} = $commentauthor;
224         $commentstate{$page}{commentauthorurl} = $commentauthorurl;
225         $commentstate{$page}{commentauthoravatar} = $params{avatar};
226         if (! defined $pagestate{$page}{meta}{author}) {
227                 $pagestate{$page}{meta}{author} = $commentauthor;
228         }
229         if (! defined $pagestate{$page}{meta}{authorurl}) {
230                 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
231         }
232
233         if ($config{comments_allowauthor}) {
234                 if (defined $params{claimedauthor}) {
235                         $pagestate{$page}{meta}{author} = $params{claimedauthor};
236                 }
237
238                 if (defined $params{url}) {
239                         my $url=$params{url};
240
241                         eval q{use URI::Heuristic}; 
242                         if (! $@) {
243                                 $url=URI::Heuristic::uf_uristr($url);
244                         }
245
246                         if (safeurl($url)) {
247                                 $pagestate{$page}{meta}{authorurl} = $url;
248                         }
249                 }
250         }
251         else {
252                 $pagestate{$page}{meta}{author} = $commentauthor;
253                 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
254         }
255
256         if (defined $params{subject}) {
257                 # decode title the same way meta does
258                 eval q{use HTML::Entities};
259                 $pagestate{$page}{meta}{title} = decode_entities($params{subject});
260         }
261
262         if ($params{page} =~ m/\/\Q$config{comments_pagename}\E\d+_/) {
263                 $pagestate{$page}{meta}{permalink} = urlto(IkiWiki::dirname($params{page})).
264                         "#".page_to_id($params{page});
265         }
266
267         eval q{use Date::Parse};
268         if (! $@) {
269                 my $time = str2time($params{date});
270                 $IkiWiki::pagectime{$page} = $time if defined $time;
271         }
272
273         return $content;
274 }
275
276 sub preprocess_moderation {
277         my %params = @_;
278
279         $params{desc}=gettext("Comment Moderation")
280                 unless defined $params{desc};
281
282         if (length $config{cgiurl}) {
283                 return '<a href="'.
284                         IkiWiki::cgiurl(do => 'commentmoderation').
285                         '">'.$params{desc}.'</a>';
286         }
287         else {
288                 return $params{desc};
289         }
290 }
291
292 sub sessioncgi ($$) {
293         my $cgi=shift;
294         my $session=shift;
295
296         my $do = $cgi->param('do');
297         if ($do eq 'comment') {
298                 editcomment($cgi, $session);
299         }
300         elsif ($do eq 'commentmoderation') {
301                 commentmoderation($cgi, $session);
302         }
303         elsif ($do eq 'commentsignin') {
304                 IkiWiki::cgi_signin($cgi, $session);
305                 exit;
306         }
307 }
308
309 # Mostly cargo-culted from IkiWiki::plugin::editpage
310 sub editcomment ($$) {
311         my $cgi=shift;
312         my $session=shift;
313
314         IkiWiki::decode_cgi_utf8($cgi);
315
316         eval q{use CGI::FormBuilder};
317         error($@) if $@;
318
319         my @buttons = (POST_COMMENT, PREVIEW, CANCEL);
320         my $form = CGI::FormBuilder->new(
321                 fields => [qw{do sid page subject editcontent type author
322                         email url subscribe anonsubscribe}],
323                 charset => 'utf-8',
324                 method => 'POST',
325                 required => [qw{editcontent}],
326                 javascript => 0,
327                 params => $cgi,
328                 action => IkiWiki::cgiurl(),
329                 header => 0,
330                 table => 0,
331                 template => { template('editcomment.tmpl') },
332         );
333
334         IkiWiki::decode_form_utf8($form);
335         IkiWiki::run_hooks(formbuilder_setup => sub {
336                         shift->(title => "comment", form => $form, cgi => $cgi,
337                                 session => $session, buttons => \@buttons);
338                 });
339         IkiWiki::decode_form_utf8($form);
340
341         my $type = $form->param('type');
342         if (defined $type && length $type && $IkiWiki::hooks{htmlize}{$type}) {
343                 $type = IkiWiki::possibly_foolish_untaint($type);
344         }
345         else {
346                 $type = $config{default_pageext};
347         }
348
349
350         my @page_types;
351         if (exists $IkiWiki::hooks{htmlize}) {
352                 foreach my $key (grep { !/^_/ && isallowed($_) } keys %{$IkiWiki::hooks{htmlize}}) {
353                         push @page_types, [$key, $IkiWiki::hooks{htmlize}{$key}{longname} || $key];
354                 }
355         }
356         @page_types=sort @page_types;
357
358         $form->field(name => 'do', type => 'hidden');
359         $form->field(name => 'sid', type => 'hidden', value => $session->id,
360                 force => 1);
361         $form->field(name => 'page', type => 'hidden');
362         $form->field(name => 'subject', type => 'text', size => 72);
363         $form->field(name => 'editcontent', type => 'textarea', rows => 10);
364         $form->field(name => "type", value => $type, force => 1,
365                 type => 'select', options => \@page_types);
366
367         my $username=$session->param('name');
368         $form->tmpl_param(username => $username);
369                 
370         $form->field(name => "subscribe", type => 'hidden');
371         $form->field(name => "anonsubscribe", type => 'hidden');
372         if (IkiWiki::Plugin::notifyemail->can("subscribe")) {
373                 if (defined $username) {
374                         $form->field(name => "subscribe", type => "checkbox",
375                                 options => [gettext("email replies to me")]);
376                 }
377                 elsif (IkiWiki::Plugin::passwordauth->can("anonuser")) {
378                         $form->field(name => "anonsubscribe", type => "checkbox",
379                                 options => [gettext("email replies to me")]);
380                 }
381         }
382
383         if ($config{comments_allowauthor} and
384             ! defined $session->param('name')) {
385                 $form->tmpl_param(allowauthor => 1);
386                 $form->field(name => 'author', type => 'text', size => '40');
387                 $form->field(name => 'email', type => 'text', size => '40');
388                 $form->field(name => 'url', type => 'text', size => '40');
389         }
390         else {
391                 $form->tmpl_param(allowauthor => 0);
392                 $form->field(name => 'author', type => 'hidden', value => '',
393                         force => 1);
394                 $form->field(name => 'email', type => 'hidden', value => '',
395                         force => 1);
396                 $form->field(name => 'url', type => 'hidden', value => '',
397                         force => 1);
398         }
399
400         if (! defined $session->param('name')) {
401                 # Make signinurl work and return here.
402                 $form->tmpl_param(signinurl => IkiWiki::cgiurl(do => 'commentsignin'));
403                 $session->param(postsignin => $ENV{QUERY_STRING});
404                 IkiWiki::cgi_savesession($session);
405         }
406
407         # The untaint is OK (as in editpage) because we're about to pass
408         # it to file_pruned and wiki_file_regexp anyway.
409         my ($page) = $form->field('page')=~/$config{wiki_file_regexp}/;
410         $page = IkiWiki::possibly_foolish_untaint($page);
411         if (! defined $page || ! length $page ||
412                 IkiWiki::file_pruned($page)) {
413                 error(gettext("bad page name"));
414         }
415
416         $form->title(sprintf(gettext("commenting on %s"),
417                         IkiWiki::pagetitle(IkiWiki::basename($page))));
418
419         $form->tmpl_param('helponformattinglink',
420                 htmllink($page, $page, 'ikiwiki/formatting',
421                         noimageinline => 1,
422                         linktext => 'FormattingHelp'),
423                         allowdirectives => $config{allow_directives});
424
425         if ($form->submitted eq CANCEL) {
426                 # bounce back to the page they wanted to comment on, and exit.
427                 IkiWiki::redirect($cgi, urlto($page));
428                 exit;
429         }
430
431         if (not exists $pagesources{$page}) {
432                 error(sprintf(gettext(
433                         "page '%s' doesn't exist, so you can't comment"),
434                         $page));
435         }
436
437         if (pagespec_match($page, $config{comments_closed_pagespec},
438                 location => $page)) {
439                 error(sprintf(gettext(
440                         "comments on page '%s' are closed"),
441                         $page));
442         }
443
444         # Set a flag to indicate that we're posting a comment,
445         # so that postcomment() can tell it should match.
446         $postcomment=1;
447         IkiWiki::check_canedit($page, $cgi, $session);
448         $postcomment=0;
449
450         my $content = "[[!comment format=$type\n";
451
452         if (defined $session->param('name')) {
453                 my $username = $session->param('name');
454                 $username =~ s/"/&quot;/g;
455                 $content .= " username=\"$username\"\n";
456         }
457         if (defined $session->param('nickname')) {
458                 my $nickname = $session->param('nickname');
459                 $nickname =~ s/"/&quot;/g;
460                 $content .= " nickname=\"$nickname\"\n";
461         }
462         elsif (defined $session->remote_addr()) {
463                 my $ip = $session->remote_addr();
464                 if ($ip =~ m/^([.0-9]+)$/) {
465                         $content .= " ip=\"$1\"\n";
466                 }
467         }
468
469         if ($config{comments_allowauthor}) {
470                 my $author = $form->field('author');
471                 if (defined $author && length $author) {
472                         $author =~ s/"/&quot;/g;
473                         $content .= " claimedauthor=\"$author\"\n";
474                 }
475                 my $url = $form->field('url');
476                 if (defined $url && length $url) {
477                         $url =~ s/"/&quot;/g;
478                         $content .= " url=\"$url\"\n";
479                 }
480         }
481
482         my $avatar=getavatar($session->param('name'));
483         if (defined $avatar && length $avatar) {
484                 $avatar =~ s/"/&quot;/g;
485                 $content .= " avatar=\"$avatar\"\n";
486         }
487
488         my $subject = $form->field('subject');
489         if (defined $subject && length $subject) {
490                 $subject =~ s/"/&quot;/g;
491         }
492         else {
493                 $subject = "comment ".(num_comments($page, $config{srcdir}) + 1);
494         }
495         $content .= " subject=\"$subject\"\n";
496
497         $content .= " date=\"" . strftime_utf8('%Y-%m-%dT%H:%M:%SZ', gmtime) . "\"\n";
498
499         my $editcontent = $form->field('editcontent');
500         $editcontent="" if ! defined $editcontent;
501         $editcontent =~ s/\r\n/\n/g;
502         $editcontent =~ s/\r/\n/g;
503         $editcontent =~ s/"/\\"/g;
504         $content .= " content=\"\"\"\n$editcontent\n\"\"\"]]\n";
505
506         my $location=unique_comment_location($page, $content, $config{srcdir});
507
508         # This is essentially a simplified version of editpage:
509         # - the user does not control the page that's created, only the parent
510         # - it's always a create operation, never an edit
511         # - this means that conflicts should never happen
512         # - this means that if they do, rocks fall and everyone dies
513
514         if ($form->submitted eq PREVIEW) {
515                 my $preview=previewcomment($content, $location, $page, time);
516                 IkiWiki::run_hooks(format => sub {
517                         $preview = shift->(page => $page,
518                                 content => $preview);
519                 });
520                 $form->tmpl_param(page_preview => $preview);
521         }
522         else {
523                 $form->tmpl_param(page_preview => "");
524         }
525
526         if ($form->submitted eq POST_COMMENT && $form->validate) {
527                 IkiWiki::checksessionexpiry($cgi, $session);
528
529                 if (IkiWiki::Plugin::notifyemail->can("subscribe")) {
530                         my $subspec="comment($page)";
531                         if (defined $username &&
532                             length $form->field("subscribe")) {
533                                 IkiWiki::Plugin::notifyemail::subscribe(
534                                         $username, $subspec);
535                         }
536                         elsif (length $form->field("email") &&
537                                length $form->field("anonsubscribe")) {
538                                 IkiWiki::Plugin::notifyemail::anonsubscribe(
539                                         $form->field("email"), $subspec);
540                         }
541                 }
542                 
543                 $postcomment=1;
544                 my $ok=IkiWiki::check_content(content => $form->field('editcontent'),
545                         subject => $form->field('subject'),
546                         $config{comments_allowauthor} ? (
547                                 author => $form->field('author'),
548                                 url => $form->field('url'),
549                         ) : (),
550                         page => $location,
551                         cgi => $cgi,
552                         session => $session,
553                         nonfatal => 1,
554                 );
555                 $postcomment=0;
556
557                 if (! $ok) {
558                         $location=unique_comment_location($page, $content, $config{srcdir}, "._comment_pending");
559                         writefile("$location._comment_pending", $config{srcdir}, $content);
560
561                         # Refresh so anything that deals with pending
562                         # comments can be updated.
563                         require IkiWiki::Render;
564                         IkiWiki::refresh();
565                         IkiWiki::saveindex();
566
567                         IkiWiki::printheader($session);
568                         print IkiWiki::cgitemplate($cgi, gettext(gettext("comment stored for moderation")),
569                                 "<p>".
570                                 gettext("Your comment will be posted after moderator review").
571                                 "</p>");
572                         exit;
573                 }
574
575                 # FIXME: could probably do some sort of graceful retry
576                 # on error? Would require significant unwinding though
577                 my $file = "$location._comment";
578                 writefile($file, $config{srcdir}, $content);
579
580                 my $conflict;
581
582                 if ($config{rcs} and $config{comments_commit}) {
583                         my $message = gettext("Added a comment");
584                         if (defined $form->field('subject') &&
585                                 length $form->field('subject')) {
586                                 $message = sprintf(
587                                         gettext("Added a comment: %s"),
588                                         $form->field('subject'));
589                         }
590
591                         IkiWiki::rcs_add($file);
592                         IkiWiki::disable_commit_hook();
593                         $conflict = IkiWiki::rcs_commit_staged(
594                                 message => $message,
595                                 session => $session,
596                         );
597                         IkiWiki::enable_commit_hook();
598                         IkiWiki::rcs_update();
599                 }
600
601                 # Now we need a refresh
602                 require IkiWiki::Render;
603                 IkiWiki::refresh();
604                 IkiWiki::saveindex();
605
606                 # this should never happen, unless a committer deliberately
607                 # breaks it or something
608                 error($conflict) if defined $conflict;
609
610                 # Jump to the new comment on the page.
611                 # The trailing question mark tries to avoid broken
612                 # caches and get the most recent version of the page.
613                 IkiWiki::redirect($cgi, urlto($page).
614                         "?updated#".page_to_id($location));
615
616         }
617         else {
618                 IkiWiki::showform($form, \@buttons, $session, $cgi,
619                         page => $page);
620         }
621
622         exit;
623 }
624
625 sub getavatar ($) {
626         my $user=shift;
627         return undef unless defined $user;
628
629         my $avatar;
630         eval q{use Libravatar::URL};
631         if (! $@) {
632                 my $oiduser = eval { IkiWiki::openiduser($user) };
633                 my $https=defined $config{url} && $config{url}=~/^https:/;
634
635                 if (defined $oiduser) {
636                         eval {
637                                 $avatar = libravatar_url(openid => $user, https => $https);
638                         }
639                 }
640                 if (! defined $avatar &&
641                     (my $email = IkiWiki::userinfo_get($user, 'email'))) {
642                         eval {
643                                 $avatar = libravatar_url(email => $email, https => $https);
644                         }
645                 }
646         }
647         return $avatar;
648 }
649
650
651 sub commentmoderation ($$) {
652         my $cgi=shift;
653         my $session=shift;
654
655         IkiWiki::needsignin($cgi, $session);
656         if (! IkiWiki::is_admin($session->param("name"))) {
657                 error(gettext("you are not logged in as an admin"));
658         }
659
660         IkiWiki::decode_cgi_utf8($cgi);
661         
662         if (defined $cgi->param('sid')) {
663                 IkiWiki::checksessionexpiry($cgi, $session);
664
665                 my $rejectalldefer=$cgi->param('rejectalldefer');
666
667                 my %vars=$cgi->Vars;
668                 my $added=0;
669                 foreach my $id (keys %vars) {
670                         if ($id =~ /(.*)\._comment(?:_pending)?$/) {
671                                 $id=decode_utf8($id);
672                                 my $action=$cgi->param($id);
673                                 next if $action eq 'Defer' && ! $rejectalldefer;
674
675                                 # Make sure that the id is of a legal
676                                 # pending comment.
677                                 my ($f) = $id =~ /$config{wiki_file_regexp}/;
678                                 if (! defined $f || ! length $f ||
679                                     IkiWiki::file_pruned($f)) {
680                                         error("illegal file");
681                                 }
682
683                                 my $page=IkiWiki::dirname($f);
684                                 my $file="$config{srcdir}/$f";
685                                 my $filedir=$config{srcdir};
686                                 if (! -e $file) {
687                                         # old location
688                                         $file="$config{wikistatedir}/comments_pending/".$f;
689                                         $filedir="$config{wikistatedir}/comments_pending";
690                                 }
691
692                                 if ($action eq 'Accept') {
693                                         my $content=eval { readfile($file) };
694                                         next if $@; # file vanished since form was displayed
695                                         my $dest=unique_comment_location($page, $content, $config{srcdir})."._comment";
696                                         writefile($dest, $config{srcdir}, $content);
697                                         if ($config{rcs} and $config{comments_commit}) {
698                                                 IkiWiki::rcs_add($dest);
699                                         }
700                                         $added++;
701                                 }
702
703                                 require IkiWiki::Render;
704                                 IkiWiki::prune($file, $filedir);
705                         }
706                 }
707
708                 if ($added) {
709                         my $conflict;
710                         if ($config{rcs} and $config{comments_commit}) {
711                                 my $message = gettext("Comment moderation");
712                                 IkiWiki::disable_commit_hook();
713                                 $conflict=IkiWiki::rcs_commit_staged(
714                                         message => $message,
715                                         session => $session,
716                                 );
717                                 IkiWiki::enable_commit_hook();
718                                 IkiWiki::rcs_update();
719                         }
720                 
721                         # Now we need a refresh
722                         require IkiWiki::Render;
723                         IkiWiki::refresh();
724                         IkiWiki::saveindex();
725                 
726                         error($conflict) if defined $conflict;
727                 }
728         }
729
730         my @comments=map {
731                 my ($id, $dir, $ctime)=@{$_};
732                 my $content=readfile("$dir/$id");
733                 my $preview=previewcomment($content, $id,
734                         $id, $ctime);
735                 {
736                         id => $id,
737                         view => $preview,
738                 }
739         } sort { $b->[2] <=> $a->[2] } comments_pending();
740
741         my $template=template("commentmoderation.tmpl");
742         $template->param(
743                 sid => $session->id,
744                 comments => \@comments,
745                 cgiurl => IkiWiki::cgiurl(),
746         );
747         IkiWiki::printheader($session);
748         my $out=$template->output;
749         IkiWiki::run_hooks(format => sub {
750                 $out = shift->(page => "", content => $out);
751         });
752         print IkiWiki::cgitemplate($cgi, gettext("comment moderation"), $out);
753         exit;
754 }
755
756 sub formbuilder_setup (@) {
757         my %params=@_;
758
759         my $form=$params{form};
760         if ($form->title eq "preferences" &&
761             IkiWiki::is_admin($params{session}->param("name"))) {
762                 push @{$params{buttons}}, "Comment Moderation";
763                 if ($form->submitted && $form->submitted eq "Comment Moderation") {
764                         commentmoderation($params{cgi}, $params{session});
765                 }
766         }
767 }
768
769 sub comments_pending () {
770         my @ret;
771
772         eval q{use File::Find};
773         error($@) if $@;
774         eval q{use Cwd};
775         error($@) if $@;
776         my $origdir=getcwd();
777
778         my $find_comments=sub {
779                 my $dir=shift;
780                 my $extension=shift;
781                 return unless -d $dir;
782
783                 chdir($dir) || die "chdir $dir: $!";
784
785                 find({
786                         no_chdir => 1,
787                         wanted => sub {
788                                 my $file=decode_utf8($_);
789                                 $file=~s/^\.\///;
790                                 return if ! length $file || IkiWiki::file_pruned($file)
791                                         || -l $_ || -d _ || $file !~ /\Q$extension\E$/;
792                                 my ($f) = $file =~ /$config{wiki_file_regexp}/; # untaint
793                                 if (defined $f) {
794                                         my $ctime=(stat($_))[10];
795                                         push @ret, [$f, $dir, $ctime];
796                                 }
797                         }
798                 }, ".");
799
800                 chdir($origdir) || die "chdir $origdir: $!";
801         };
802         
803         $find_comments->($config{srcdir}, "._comment_pending");
804         # old location
805         $find_comments->("$config{wikistatedir}/comments_pending/",
806                 "._comment");
807
808         return @ret;
809 }
810
811 sub previewcomment ($$$) {
812         my $content=shift;
813         my $location=shift;
814         my $page=shift;
815         my $time=shift;
816
817         # Previewing a comment should implicitly enable comment posting mode.
818         my $oldpostcomment=$postcomment;
819         $postcomment=1;
820
821         my $preview = IkiWiki::htmlize($location, $page, '_comment',
822                         IkiWiki::linkify($location, $page,
823                         IkiWiki::preprocess($location, $page,
824                         IkiWiki::filter($location, $page, $content), 0, 1)));
825
826         my $template = template("comment.tmpl");
827         $template->param(content => $preview);
828         $template->param(ctime => displaytime($time, undef, 1));
829         $template->param(html5 => $config{html5});
830
831         IkiWiki::run_hooks(pagetemplate => sub {
832                 shift->(page => $location,
833                         destpage => $page,
834                         template => $template);
835         });
836
837         $template->param(have_actions => 0);
838
839         $postcomment=$oldpostcomment;
840
841         return $template->output;
842 }
843
844 sub commentsshown ($) {
845         my $page=shift;
846
847         return pagespec_match($page, $config{comments_pagespec},
848                 location => $page);
849 }
850
851 sub commentsopen ($) {
852         my $page = shift;
853
854         return length $config{cgiurl} > 0 &&
855                (! length $config{comments_closed_pagespec} ||
856                 ! pagespec_match($page, $config{comments_closed_pagespec},
857                                  location => $page));
858 }
859
860 sub pagetemplate (@) {
861         my %params = @_;
862
863         my $page = $params{page};
864         my $template = $params{template};
865         my $shown = ($template->query(name => 'commentslink') ||
866                      $template->query(name => 'commentsurl') ||
867                      $template->query(name => 'atomcommentsurl') ||
868                      $template->query(name => 'comments')) &&
869                     commentsshown($page);
870
871         if ($template->query(name => 'comments')) {
872                 my $comments = undef;
873                 if ($shown) {
874                         $comments = IkiWiki::preprocess_inline(
875                                 pages => "comment($page) and !comment($page/*)",
876                                 template => 'comment',
877                                 show => 0,
878                                 reverse => 'yes',
879                                 page => $page,
880                                 destpage => $params{destpage},
881                                 feedfile => 'comments',
882                                 emptyfeeds => 'no',
883                         );
884                 }
885
886                 if (defined $comments && length $comments) {
887                         $template->param(comments => $comments);
888                 }
889
890                 if ($shown && commentsopen($page)) {
891                         $template->param(addcommenturl => addcommenturl($page));
892                 }
893         }
894
895         if ($shown) {
896                 if ($template->query(name => 'commentsurl')) {
897                         $template->param(commentsurl =>
898                                 urlto($page).'#comments');
899                 }
900
901                 if ($template->query(name => 'atomcommentsurl') && $config{usedirs}) {
902                         # This will 404 until there are some comments, but I
903                         # think that's probably OK...
904                         $template->param(atomcommentsurl =>
905                                 urlto($page).'comments.atom');
906                 }
907
908                 if ($template->query(name => 'commentslink')) {
909                         my $num=num_comments($page, $config{srcdir});
910                         my $link;
911                         if ($num > 0) {
912                                 $link = htmllink($page, $params{destpage}, $page,
913                                         linktext => sprintf(ngettext("%i comment", "%i comments", $num), $num),
914                                         anchor => "comments",
915                                         noimageinline => 1
916                                 );
917                         }
918                         elsif (commentsopen($page)) {
919                                 $link = "<a href=\"".addcommenturl($page)."\">".
920                                         #translators: Here "Comment" is a verb;
921                                         #translators: the user clicks on it to
922                                         #translators: post a comment.
923                                         gettext("Comment").
924                                         "</a>";
925                         }
926                         $template->param(commentslink => $link)
927                                 if defined $link;
928                 }
929         }
930
931         # everything below this point is only relevant to the comments
932         # themselves
933         if (!exists $commentstate{$page}) {
934                 return;
935         }
936         
937         if ($template->query(name => 'commentid')) {
938                 $template->param(commentid => page_to_id($page));
939         }
940
941         if ($template->query(name => 'commentuser')) {
942                 $template->param(commentuser =>
943                         $commentstate{$page}{commentuser});
944         }
945
946         if ($template->query(name => 'commentopenid')) {
947                 $template->param(commentopenid =>
948                         $commentstate{$page}{commentopenid});
949         }
950
951         if ($template->query(name => 'commentip')) {
952                 $template->param(commentip =>
953                         $commentstate{$page}{commentip});
954         }
955
956         if ($template->query(name => 'commentauthor')) {
957                 $template->param(commentauthor =>
958                         $commentstate{$page}{commentauthor});
959         }
960
961         if ($template->query(name => 'commentauthorurl')) {
962                 $template->param(commentauthorurl =>
963                         $commentstate{$page}{commentauthorurl});
964         }
965
966         if ($template->query(name => 'commentauthoravatar')) {
967                 $template->param(commentauthoravatar =>
968                         $commentstate{$page}{commentauthoravatar});
969         }
970
971         if ($template->query(name => 'removeurl') &&
972             IkiWiki::Plugin::remove->can("check_canremove") &&
973             length $config{cgiurl}) {
974                 $template->param(removeurl => IkiWiki::cgiurl(do => 'remove',
975                         page => $page));
976                 $template->param(have_actions => 1);
977         }
978 }
979
980 sub addcommenturl ($) {
981         my $page=shift;
982
983         return IkiWiki::cgiurl(do => 'comment', page => $page);
984 }
985
986 sub num_comments ($$) {
987         my $page=shift;
988         my $dir=shift;
989
990         my @comments=glob("$dir/$page/$config{comments_pagename}*._comment");
991         return int @comments;
992 }
993
994 sub unique_comment_location ($$$$) {
995         my $page=shift;
996         eval q{use Digest::MD5 'md5_hex'};
997         error($@) if $@;
998         my $content_md5=md5_hex(Encode::encode_utf8(shift));
999         my $dir=shift;
1000         my $ext=shift || "._comment";
1001
1002         my $location;
1003         my $i = num_comments($page, $dir);
1004         do {
1005                 $i++;
1006                 $location = "$page/$config{comments_pagename}${i}_${content_md5}";
1007         } while (-e "$dir/$location$ext");
1008
1009         return $location;
1010 }
1011
1012 sub page_to_id ($) {
1013         # Converts a comment page name into a unique, legal html id
1014         # attribute value, that can be used as an anchor to link to the
1015         # comment.
1016         my $page=shift;
1017
1018         eval q{use Digest::MD5 'md5_hex'};
1019         error($@) if $@;
1020
1021         return "comment-".md5_hex(Encode::encode_utf8(($page)));
1022 }
1023         
1024 package IkiWiki::PageSpec;
1025
1026 sub match_postcomment ($$;@) {
1027         my $page = shift;
1028         my $glob = shift;
1029
1030         if (! $postcomment) {
1031                 return IkiWiki::FailReason->new("not posting a comment");
1032         }
1033         return match_glob($page, $glob, @_);
1034 }
1035
1036 sub match_comment ($$;@) {
1037         my $page = shift;
1038         my $glob = shift;
1039
1040         if (! $postcomment) {
1041                 # To see if it's a comment, check the source file type.
1042                 # Deal with comments that were just deleted.
1043                 my $source=exists $IkiWiki::pagesources{$page} ?
1044                         $IkiWiki::pagesources{$page} :
1045                         $IkiWiki::delpagesources{$page};
1046                 my $type=defined $source ? IkiWiki::pagetype($source) : undef;
1047                 if (! defined $type || $type ne "_comment") {
1048                         return IkiWiki::FailReason->new("$page is not a comment");
1049                 }
1050         }
1051
1052         return match_glob($page, "$glob/*", internal => 1, @_);
1053 }
1054
1055 sub match_comment_pending ($$;@) {
1056         my $page = shift;
1057         my $glob = shift;
1058         
1059         my $source=exists $IkiWiki::pagesources{$page} ?
1060                 $IkiWiki::pagesources{$page} :
1061                 $IkiWiki::delpagesources{$page};
1062         my $type=defined $source ? IkiWiki::pagetype($source) : undef;
1063         if (! defined $type || $type ne "_comment_pending") {
1064                 return IkiWiki::FailReason->new("$page is not a pending comment");
1065         }
1066
1067         return match_glob($page, "$glob/*", internal => 1, @_);
1068 }
1069
1070 1