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