disable only_committed_changes when uncommitted files are created by plugins
[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         if (! $config{comments_commit}) {
108                 $config{only_committed_changes}=0;
109         }
110         $config{comments_pagespec} = ''
111                 unless defined $config{comments_pagespec};
112         $config{comments_closed_pagespec} = ''
113                 unless defined $config{comments_closed_pagespec};
114         $config{comments_pagename} = 'comment_'
115                 unless defined $config{comments_pagename};
116         $config{comments_allowformats} = ''
117                 unless defined $config{comments_allowformats};
118 }
119
120 sub htmlize {
121         my %params = @_;
122         return $params{content};
123 }
124
125 sub htmlize_pending {
126         my %params = @_;
127         return sprintf(gettext("this comment needs %s"),
128                 '<a href="'.
129                 IkiWiki::cgiurl(do => "commentmoderation").'">'.
130                 gettext("moderation").'</a>');
131 }
132
133 # FIXME: copied verbatim from meta
134 sub safeurl ($) {
135         my $url=shift;
136         if (exists $IkiWiki::Plugin::htmlscrubber::{safe_url_regexp} &&
137             defined $IkiWiki::Plugin::htmlscrubber::safe_url_regexp) {
138                 return $url=~/$IkiWiki::Plugin::htmlscrubber::safe_url_regexp/;
139         }
140         else {
141                 return 1;
142         }
143 }
144
145 sub isallowed ($) {
146     my $format = shift;
147     return ! $config{comments_allowformats} || $config{comments_allowformats} =~ /\b$format\b/;
148 }
149
150 sub preprocess {
151         my %params = @_;
152         my $page = $params{page};
153
154         my $format = $params{format};
155         if (defined $format && (! exists $IkiWiki::hooks{htmlize}{$format} ||
156                                 ! isallowed($format))) {
157                 error(sprintf(gettext("unsupported page format %s"), $format));
158         }
159
160         my $content = $params{content};
161         if (! defined $content) {
162                 error(gettext("comment must have content"));
163         }
164         $content =~ s/\\"/"/g;
165
166         if (defined wantarray) {
167                 if ($config{comments_allowdirectives}) {
168                         $content = IkiWiki::preprocess($page, $params{destpage},
169                                 $content);
170                 }
171
172                 # no need to bother with htmlize if it's just HTML
173                 $content = IkiWiki::htmlize($page, $params{destpage}, $format, $content)
174                         if defined $format;
175
176                 IkiWiki::run_hooks(sanitize => sub {
177                         $content = shift->(
178                                 page => $page,
179                                 destpage => $params{destpage},
180                                 content => $content,
181                         );
182                 });
183         }
184         else {
185                 IkiWiki::preprocess($page, $params{destpage}, $content, 1);
186         }
187
188         # set metadata, possibly overriding [[!meta]] directives from the
189         # comment itself
190
191         my $commentuser;
192         my $commentip;
193         my $commentauthor;
194         my $commentauthorurl;
195         my $commentopenid;
196         if (defined $params{username}) {
197                 $commentuser = $params{username};
198
199                 my $oiduser = eval { IkiWiki::openiduser($commentuser) };
200
201                 if (defined $oiduser) {
202                         # looks like an OpenID
203                         $commentauthorurl = $commentuser;
204                         $commentauthor = (defined $params{nickname} && length $params{nickname}) ? $params{nickname} : $oiduser;
205                         $commentopenid = $commentuser;
206                 }
207                 else {
208                         $commentauthorurl = IkiWiki::cgiurl(
209                                 do => 'goto',
210                                 page => IkiWiki::userpage($commentuser)
211                         );
212
213                         $commentauthor = $commentuser;
214                 }
215         }
216         else {
217                 if (defined $params{ip}) {
218                         $commentip = $params{ip};
219                 }
220                 $commentauthor = gettext("Anonymous");
221         }
222
223         $commentstate{$page}{commentuser} = $commentuser;
224         $commentstate{$page}{commentopenid} = $commentopenid;
225         $commentstate{$page}{commentip} = $commentip;
226         $commentstate{$page}{commentauthor} = $commentauthor;
227         $commentstate{$page}{commentauthorurl} = $commentauthorurl;
228         $commentstate{$page}{commentauthoravatar} = $params{avatar};
229         if (! defined $pagestate{$page}{meta}{author}) {
230                 $pagestate{$page}{meta}{author} = $commentauthor;
231         }
232         if (! defined $pagestate{$page}{meta}{authorurl}) {
233                 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
234         }
235
236         if ($config{comments_allowauthor}) {
237                 if (defined $params{claimedauthor}) {
238                         $pagestate{$page}{meta}{author} = $params{claimedauthor};
239                 }
240
241                 if (defined $params{url}) {
242                         my $url=$params{url};
243
244                         eval q{use URI::Heuristic}; 
245                         if (! $@) {
246                                 $url=URI::Heuristic::uf_uristr($url);
247                         }
248
249                         if (safeurl($url)) {
250                                 $pagestate{$page}{meta}{authorurl} = $url;
251                         }
252                 }
253         }
254         else {
255                 $pagestate{$page}{meta}{author} = $commentauthor;
256                 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
257         }
258
259         if (defined $params{subject}) {
260                 # decode title the same way meta does
261                 eval q{use HTML::Entities};
262                 $pagestate{$page}{meta}{title} = decode_entities($params{subject});
263         }
264
265         if ($params{page} =~ m/\/\Q$config{comments_pagename}\E\d+_/) {
266                 $pagestate{$page}{meta}{permalink} = urlto(IkiWiki::dirname($params{page})).
267                         "#".page_to_id($params{page});
268         }
269
270         eval q{use Date::Parse};
271         if (! $@) {
272                 my $time = str2time($params{date});
273                 $IkiWiki::pagectime{$page} = $time if defined $time;
274         }
275
276         return $content;
277 }
278
279 sub preprocess_moderation {
280         my %params = @_;
281
282         $params{desc}=gettext("Comment Moderation")
283                 unless defined $params{desc};
284
285         if (length $config{cgiurl}) {
286                 return '<a href="'.
287                         IkiWiki::cgiurl(do => 'commentmoderation').
288                         '">'.$params{desc}.'</a>';
289         }
290         else {
291                 return $params{desc};
292         }
293 }
294
295 sub sessioncgi ($$) {
296         my $cgi=shift;
297         my $session=shift;
298
299         my $do = $cgi->param('do');
300         if ($do eq 'comment') {
301                 editcomment($cgi, $session);
302         }
303         elsif ($do eq 'commentmoderation') {
304                 commentmoderation($cgi, $session);
305         }
306         elsif ($do eq 'commentsignin') {
307                 IkiWiki::cgi_signin($cgi, $session);
308                 exit;
309         }
310 }
311
312 # Mostly cargo-culted from IkiWiki::plugin::editpage
313 sub editcomment ($$) {
314         my $cgi=shift;
315         my $session=shift;
316
317         IkiWiki::decode_cgi_utf8($cgi);
318
319         eval q{use CGI::FormBuilder};
320         error($@) if $@;
321
322         my @buttons = (POST_COMMENT, PREVIEW, CANCEL);
323         my $form = CGI::FormBuilder->new(
324                 fields => [qw{do sid page subject editcontent type author
325                         email url subscribe anonsubscribe}],
326                 charset => 'utf-8',
327                 method => 'POST',
328                 required => [qw{editcontent}],
329                 javascript => 0,
330                 params => $cgi,
331                 action => IkiWiki::cgiurl(),
332                 header => 0,
333                 table => 0,
334                 template => { template('editcomment.tmpl') },
335         );
336
337         IkiWiki::decode_form_utf8($form);
338         IkiWiki::run_hooks(formbuilder_setup => sub {
339                         shift->(title => "comment", form => $form, cgi => $cgi,
340                                 session => $session, buttons => \@buttons);
341                 });
342         IkiWiki::decode_form_utf8($form);
343
344         my $type = $form->param('type');
345         if (defined $type && length $type && $IkiWiki::hooks{htmlize}{$type}) {
346                 $type = IkiWiki::possibly_foolish_untaint($type);
347         }
348         else {
349                 $type = $config{default_pageext};
350         }
351
352
353         my @page_types;
354         if (exists $IkiWiki::hooks{htmlize}) {
355                 foreach my $key (grep { !/^_/ && isallowed($_) } keys %{$IkiWiki::hooks{htmlize}}) {
356                         push @page_types, [$key, $IkiWiki::hooks{htmlize}{$key}{longname} || $key];
357                 }
358         }
359         @page_types=sort @page_types;
360
361         $form->field(name => 'do', type => 'hidden');
362         $form->field(name => 'sid', type => 'hidden', value => $session->id,
363                 force => 1);
364         $form->field(name => 'page', type => 'hidden');
365         $form->field(name => 'subject', type => 'text', size => 72);
366         $form->field(name => 'editcontent', type => 'textarea', rows => 10);
367         $form->field(name => "type", value => $type, force => 1,
368                 type => 'select', options => \@page_types);
369
370         my $username=$session->param('name');
371         $form->tmpl_param(username => $username);
372                 
373         $form->field(name => "subscribe", type => 'hidden');
374         $form->field(name => "anonsubscribe", type => 'hidden');
375         if (IkiWiki::Plugin::notifyemail->can("subscribe")) {
376                 if (defined $username) {
377                         $form->field(name => "subscribe", type => "checkbox",
378                                 options => [gettext("email replies to me")]);
379                 }
380                 elsif (IkiWiki::Plugin::passwordauth->can("anonuser")) {
381                         $form->field(name => "anonsubscribe", type => "checkbox",
382                                 options => [gettext("email replies to me")]);
383                 }
384         }
385
386         if ($config{comments_allowauthor} and
387             ! defined $session->param('name')) {
388                 $form->tmpl_param(allowauthor => 1);
389                 $form->field(name => 'author', type => 'text', size => '40');
390                 $form->field(name => 'email', type => 'text', size => '40');
391                 $form->field(name => 'url', type => 'text', size => '40');
392         }
393         else {
394                 $form->tmpl_param(allowauthor => 0);
395                 $form->field(name => 'author', type => 'hidden', value => '',
396                         force => 1);
397                 $form->field(name => 'email', type => 'hidden', value => '',
398                         force => 1);
399                 $form->field(name => 'url', type => 'hidden', value => '',
400                         force => 1);
401         }
402
403         if (! defined $session->param('name')) {
404                 # Make signinurl work and return here.
405                 $form->tmpl_param(signinurl => IkiWiki::cgiurl(do => 'commentsignin'));
406                 $session->param(postsignin => $ENV{QUERY_STRING});
407                 IkiWiki::cgi_savesession($session);
408         }
409
410         # The untaint is OK (as in editpage) because we're about to pass
411         # it to file_pruned and wiki_file_regexp anyway.
412         my ($page) = $form->field('page')=~/$config{wiki_file_regexp}/;
413         $page = IkiWiki::possibly_foolish_untaint($page);
414         if (! defined $page || ! length $page ||
415                 IkiWiki::file_pruned($page)) {
416                 error(gettext("bad page name"));
417         }
418
419         $form->title(sprintf(gettext("commenting on %s"),
420                         IkiWiki::pagetitle(IkiWiki::basename($page))));
421
422         $form->tmpl_param('helponformattinglink',
423                 htmllink($page, $page, 'ikiwiki/formatting',
424                         noimageinline => 1,
425                         linktext => 'FormattingHelp'),
426                         allowdirectives => $config{allow_directives});
427
428         if ($form->submitted eq CANCEL) {
429                 # bounce back to the page they wanted to comment on, and exit.
430                 IkiWiki::redirect($cgi, urlto($page));
431                 exit;
432         }
433
434         if (not exists $pagesources{$page}) {
435                 error(sprintf(gettext(
436                         "page '%s' doesn't exist, so you can't comment"),
437                         $page));
438         }
439
440         if (pagespec_match($page, $config{comments_closed_pagespec},
441                 location => $page)) {
442                 error(sprintf(gettext(
443                         "comments on page '%s' are closed"),
444                         $page));
445         }
446
447         # Set a flag to indicate that we're posting a comment,
448         # so that postcomment() can tell it should match.
449         $postcomment=1;
450         IkiWiki::check_canedit($page, $cgi, $session);
451         $postcomment=0;
452
453         my $content = "[[!comment format=$type\n";
454
455         if (defined $session->param('name')) {
456                 my $username = $session->param('name');
457                 $username =~ s/"/&quot;/g;
458                 $content .= " username=\"$username\"\n";
459         }
460         if (defined $session->param('nickname')) {
461                 my $nickname = $session->param('nickname');
462                 $nickname =~ s/"/&quot;/g;
463                 $content .= " nickname=\"$nickname\"\n";
464         }
465         elsif (defined $session->remote_addr()) {
466                 $content .= " ip=\"".$session->remote_addr()."\"\n";
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