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;
12 use POSIX qw(strftime);
14 use constant PREVIEW => "Preview";
15 use constant POST_COMMENT => "Post comment";
16 use constant CANCEL => "Cancel";
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 hook(type => "sessioncgi", id => 'comment', call => \&sessioncgi);
25 hook(type => "htmlize", id => "_comment", call => \&htmlize);
26 hook(type => "pagetemplate", id => "comments", call => \&pagetemplate);
27 hook(type => "cgi", id => "comments", call => \&linkcgi);
28 IkiWiki::loadplugin("inline");
37 # Pages where comments are shown, but new comments are not
38 # allowed, will show "Comments are closed".
39 comments_shown_pagespec => {
42 description => 'PageSpec for pages where comments will be shown inline',
43 link => 'ikiwiki/PageSpec',
47 comments_open_pagespec => {
49 example => 'blog/* and created_after(close_old_comments)',
50 description => 'PageSpec for pages where new comments can be posted',
51 link => 'ikiwiki/PageSpec',
55 comments_pagename => {
57 default => 'comment_',
58 description => 'Base name for comments, e.g. "comment_" for pages like "sandbox/comment_12"',
59 safe => 0, # manual page moving required
62 comments_allowdirectives => {
65 description => 'Interpret directives in comments?',
69 comments_allowauthor => {
72 description => 'Allow anonymous commenters to set an author name?',
79 description => 'commit comments to the VCS',
80 # old uncommitted comments are likely to cause
81 # confusion if this is changed
88 $config{comments_commit} = 1
89 unless defined $config{comments_commit};
90 $config{comments_shown_pagespec} = ''
91 unless defined $config{comments_shown_pagespec};
92 $config{comments_open_pagespec} = ''
93 unless defined $config{comments_open_pagespec};
94 $config{comments_pagename} = 'comment_'
95 unless defined $config{comments_pagename};
100 return $params{content};
103 # FIXME: copied verbatim from meta
106 if (exists $IkiWiki::Plugin::htmlscrubber::{safe_url_regexp} &&
107 defined $IkiWiki::Plugin::htmlscrubber::safe_url_regexp) {
108 return $url=~/$IkiWiki::Plugin::htmlscrubber::safe_url_regexp/;
117 my $page = $params{page};
119 my $format = $params{format};
120 if (defined $format && ! exists $IkiWiki::hooks{htmlize}{$format}) {
121 error(sprintf(gettext("unsupported page format %s"), $format));
124 my $content = $params{content};
125 if (! defined $content) {
126 error(gettext("comment must have content"));
128 $content =~ s/\\"/"/g;
130 $content = IkiWiki::filter($page, $params{destpage}, $content);
132 if ($config{comments_allowdirectives}) {
133 $content = IkiWiki::preprocess($page, $params{destpage},
137 # no need to bother with htmlize if it's just HTML
138 $content = IkiWiki::htmlize($page, $params{destpage}, $format,
139 $content) if defined $format;
141 IkiWiki::run_hooks(sanitize => sub {
144 destpage => $params{destpage},
149 # set metadata, possibly overriding [[!meta]] directives from the
155 my $commentauthorurl;
157 if (defined $params{username}) {
158 $commentuser = $params{username};
159 ($commentauthorurl, $commentauthor) =
160 linkuser($params{username});
163 if (defined $params{ip}) {
164 $commentip = $params{ip};
166 $commentauthor = gettext("Anonymous");
169 $pagestate{$page}{comments}{commentuser} = $commentuser;
170 $pagestate{$page}{comments}{commentip} = $commentip;
171 $pagestate{$page}{comments}{commentauthor} = $commentauthor;
172 $pagestate{$page}{comments}{commentauthorurl} = $commentauthorurl;
173 if (! defined $pagestate{$page}{meta}{author}) {
174 $pagestate{$page}{meta}{author} = $commentauthor;
176 if (! defined $pagestate{$page}{meta}{authorurl}) {
177 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
180 if ($config{comments_allowauthor}) {
181 if (defined $params{claimedauthor}) {
182 $pagestate{$page}{meta}{author} = $params{claimedauthor};
185 if (defined $params{url} and safeurl($params{url})) {
186 $pagestate{$page}{meta}{authorurl} = $params{url};
190 $pagestate{$page}{meta}{author} = $commentauthor;
191 $pagestate{$page}{meta}{authorurl} = $commentauthorurl;
194 if (defined $params{subject}) {
195 $pagestate{$page}{meta}{title} = $params{subject};
198 my $baseurl = urlto($params{destpage}, undef, 1);
200 if ($params{page} =~ m/\/(\Q$config{comments_pagename}\E\d+)$/) {
203 $pagestate{$page}{meta}{permalink} = "${baseurl}#${anchor}";
205 eval q{use Date::Parse};
207 my $time = str2time($params{date});
208 $IkiWiki::pagectime{$page} = $time if defined $time;
211 # FIXME: hard-coded HTML (although it's just to set an ID)
212 return "<div id=\"$anchor\">$content</div>" if $anchor;
216 # This is exactly the same as recentchanges_link :-(
219 if (defined $cgi->param('do') && $cgi->param('do') eq "commenter") {
221 my $page=decode_utf8($cgi->param("page"));
222 if (! defined $page) {
223 error("missing page parameter");
226 IkiWiki::loadindex();
228 my $link=bestlink("", $page);
229 if (! length $link) {
230 print "Content-type: text/html\n\n";
231 print IkiWiki::misctemplate(gettext(gettext("missing page")),
233 sprintf(gettext("The page %s does not exist."),
234 htmllink("", "", $page)).
238 IkiWiki::redirect($cgi, urlto($link, undef, 1));
245 # FIXME: basically the same logic as recentchanges
246 # returns (author URL, pretty-printed version)
249 my $oiduser = eval { IkiWiki::openiduser($user) };
251 if (defined $oiduser) {
252 return ($user, $oiduser);
254 # FIXME: it'd be good to avoid having such a link for anonymous
257 return (IkiWiki::cgiurl(
259 page => (length $config{userdir}
260 ? "$config{userdir}/$user"
266 # Mostly cargo-culted from IkiWiki::plugin::editpage
267 sub sessioncgi ($$) {
271 my $do = $cgi->param('do');
272 return unless $do eq 'comment';
274 IkiWiki::decode_cgi_utf8($cgi);
276 eval q{use CGI::FormBuilder};
279 my @buttons = (POST_COMMENT, PREVIEW, CANCEL);
280 my $form = CGI::FormBuilder->new(
281 fields => [qw{do sid page subject editcontent type author url}],
284 required => [qw{editcontent}],
287 action => $config{cgiurl},
290 template => scalar IkiWiki::template_params('comments_form.tmpl'),
291 # wtf does this do in editpage?
292 wikiname => $config{wikiname},
295 IkiWiki::decode_form_utf8($form);
296 IkiWiki::run_hooks(formbuilder_setup => sub {
297 shift->(title => "comment", form => $form, cgi => $cgi,
298 session => $session, buttons => \@buttons);
300 IkiWiki::decode_form_utf8($form);
302 my $type = $form->param('type');
303 if (defined $type && length $type && $IkiWiki::hooks{htmlize}{$type}) {
304 $type = IkiWiki::possibly_foolish_untaint($type);
307 $type = $config{default_pageext};
310 if (exists $IkiWiki::hooks{htmlize}) {
311 @page_types = grep { ! /^_/ } keys %{$IkiWiki::hooks{htmlize}};
314 $form->field(name => 'do', type => 'hidden');
315 $form->field(name => 'sid', type => 'hidden', value => $session->id,
317 $form->field(name => 'page', type => 'hidden');
318 $form->field(name => 'subject', type => 'text', size => 72);
319 $form->field(name => 'editcontent', type => 'textarea', rows => 10);
320 $form->field(name => "type", value => $type, force => 1,
321 type => 'select', options => \@page_types);
323 $form->tmpl_param(username => $session->param('name'));
325 if ($config{comments_allowauthor} and
326 ! defined $session->param('name')) {
327 $form->tmpl_param(allowauthor => 1);
328 $form->field(name => 'author', type => 'text', size => '40');
329 $form->field(name => 'url', type => 'text', size => '40');
332 $form->tmpl_param(allowauthor => 0);
333 $form->field(name => 'author', type => 'hidden', value => '',
335 $form->field(name => 'url', type => 'hidden', value => '',
339 # The untaint is OK (as in editpage) because we're about to pass
340 # it to file_pruned anyway
341 my $page = $form->field('page');
342 $page = IkiWiki::possibly_foolish_untaint($page);
343 if (! defined $page || ! length $page ||
344 IkiWiki::file_pruned($page, $config{srcdir})) {
345 error(gettext("bad page name"));
348 # FIXME: is this right? Or should we be using the candidate subpage
349 # (whatever that might mean) as the base URL?
350 my $baseurl = urlto($page, undef, 1);
352 $form->title(sprintf(gettext("commenting on %s"),
353 IkiWiki::pagetitle($page)));
355 $form->tmpl_param('helponformattinglink',
356 htmllink($page, $page, 'ikiwiki/formatting',
358 linktext => 'FormattingHelp'),
359 allowdirectives => $config{allow_directives});
361 if ($form->submitted eq CANCEL) {
362 # bounce back to the page they wanted to comment on, and exit.
363 # CANCEL need not be considered in future
364 IkiWiki::redirect($cgi, urlto($page, undef, 1));
368 if (not exists $pagesources{$page}) {
369 error(sprintf(gettext(
370 "page '%s' doesn't exist, so you can't comment"),
374 if (not pagespec_match($page, $config{comments_open_pagespec},
375 location => $page)) {
376 error(sprintf(gettext(
377 "comments on page '%s' are closed"),
381 # Set a flag to indicate that we're posting a comment,
382 # so that postcomment() can tell it should match.
384 IkiWiki::check_canedit($page, $cgi, $session);
387 # FIXME: rather a simplistic way to make the comments...
393 $location = "$page/$config{comments_pagename}$i";
394 } while (-e "$config{srcdir}/$location._comment");
396 my $content = "[[!_comment format=$type\n";
398 # FIXME: handling of double quotes probably wrong?
399 if (defined $session->param('name')) {
400 my $username = $session->param('name');
401 $username =~ s/"/"/g;
402 $content .= " username=\"$username\"\n";
404 elsif (defined $ENV{REMOTE_ADDR}) {
405 my $ip = $ENV{REMOTE_ADDR};
406 if ($ip =~ m/^([.0-9]+)$/) {
407 $content .= " ip=\"$1\"\n";
411 if ($config{comments_allowauthor}) {
412 my $author = $form->field('author');
413 if (length $author) {
414 $author =~ s/"/"/g;
415 $content .= " claimedauthor=\"$author\"\n";
417 my $url = $form->field('url');
419 $url =~ s/"/"/g;
420 $content .= " url=\"$url\"\n";
424 my $subject = $form->field('subject');
425 if (length $subject) {
426 $subject =~ s/"/"/g;
427 $content .= " subject=\"$subject\"\n";
430 $content .= " date=\"" . decode_utf8(strftime('%Y-%m-%dT%H:%M:%SZ', gmtime)) . "\"\n";
432 my $editcontent = $form->field('editcontent') || '';
433 $editcontent =~ s/\r\n/\n/g;
434 $editcontent =~ s/\r/\n/g;
435 $editcontent =~ s/"/\\"/g;
436 $content .= " content=\"\"\"\n$editcontent\n\"\"\"]]\n";
438 # This is essentially a simplified version of editpage:
439 # - the user does not control the page that's created, only the parent
440 # - it's always a create operation, never an edit
441 # - this means that conflicts should never happen
442 # - this means that if they do, rocks fall and everyone dies
444 if ($form->submitted eq PREVIEW) {
445 my $preview = IkiWiki::htmlize($location, $page, '_comment',
446 IkiWiki::linkify($page, $page,
447 IkiWiki::preprocess($page, $page,
448 IkiWiki::filter($location,
451 IkiWiki::run_hooks(format => sub {
452 $preview = shift->(page => $page,
453 content => $preview);
456 my $template = template("comments_display.tmpl");
457 $template->param(content => $preview);
458 $template->param(title => $form->field('subject'));
459 $template->param(ctime => displaytime(time));
461 $form->tmpl_param(page_preview => $template->output);
464 $form->tmpl_param(page_preview => "");
467 if ($form->submitted eq POST_COMMENT && $form->validate) {
468 my $file = "$location._comment";
470 IkiWiki::checksessionexpiry($cgi, $session);
472 # FIXME: could probably do some sort of graceful retry
473 # on error? Would require significant unwinding though
474 writefile($file, $config{srcdir}, $content);
478 if ($config{rcs} and $config{comments_commit}) {
479 my $message = gettext("Added a comment");
480 if (defined $form->field('subject') &&
481 length $form->field('subject')) {
483 gettext("Added a comment: %s"),
484 $form->field('subject'));
487 IkiWiki::rcs_add($file);
488 IkiWiki::disable_commit_hook();
489 $conflict = IkiWiki::rcs_commit_staged($message,
490 $session->param('name'), $ENV{REMOTE_ADDR});
491 IkiWiki::enable_commit_hook();
492 IkiWiki::rcs_update();
495 # Now we need a refresh
496 require IkiWiki::Render;
498 IkiWiki::saveindex();
500 # this should never happen, unless a committer deliberately
501 # breaks it or something
502 error($conflict) if defined $conflict;
504 # Bounce back to where we were, but defeat broken caches
505 my $anticache = "?updated=$page/$config{comments_pagename}$i";
506 IkiWiki::redirect($cgi, urlto($page, undef, 1).$anticache);
509 IkiWiki::showform ($form, \@buttons, $session, $cgi,
510 forcebaseurl => $baseurl);
516 sub pagetemplate (@) {
519 my $page = $params{page};
520 my $template = $params{template};
522 if ($template->query(name => 'comments')) {
523 my $comments = undef;
526 my $shown = pagespec_match($page,
527 $config{comments_shown_pagespec},
530 if (pagespec_match($page, "*/$config{comments_pagename}*",
531 location => $page)) {
536 if (length $config{cgiurl}) {
537 $open = pagespec_match($page,
538 $config{comments_open_pagespec},
543 $comments = IkiWiki::preprocess_inline(
544 pages => "internal($page/$config{comments_pagename}*)",
545 template => 'comments_display',
549 destpage => $params{destpage},
550 feedfile => 'comments',
555 if (defined $comments && length $comments) {
556 $template->param(comments => $comments);
560 my $commenturl = IkiWiki::cgiurl(do => 'comment',
562 $template->param(commenturl => $commenturl);
566 if ($template->query(name => 'commentuser')) {
567 $template->param(commentuser =>
568 $pagestate{$page}{comments}{commentuser});
571 if ($template->query(name => 'commentip')) {
572 $template->param(commentip =>
573 $pagestate{$page}{comments}{commentip});
576 if ($template->query(name => 'commentauthor')) {
577 $template->param(commentauthor =>
578 $pagestate{$page}{comments}{commentauthor});
581 if ($template->query(name => 'commentauthorurl')) {
582 $template->param(commentauthorurl =>
583 $pagestate{$page}{comments}{commentauthorurl});
587 package IkiWiki::PageSpec;
589 sub match_postcomment ($$;@) {
593 if (! $postcomment) {
594 return IkiWiki::FailReason->new("not posting a comment");
596 return match_glob($page, $glob);