summaryrefslogtreecommitdiff
path: root/IkiWiki/Plugin/comments.pm
blob: 46f71e2e7c216485a0ca54f1c4724dd9be8ccab6 (plain)
  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. use warnings;
  8. use strict;
  9. use IkiWiki 2.00;
  10. use Encode;
  11. use constant PREVIEW => "Preview";
  12. use constant POST_COMMENT => "Post comment";
  13. use constant CANCEL => "Cancel";
  14. sub import { #{{{
  15. hook(type => "checkconfig", id => 'comments', call => \&checkconfig);
  16. hook(type => "getsetup", id => 'comments', call => \&getsetup);
  17. hook(type => "preprocess", id => 'comment', call => \&preprocess);
  18. hook(type => "sessioncgi", id => 'comment', call => \&sessioncgi);
  19. hook(type => "htmlize", id => "_comment", call => \&htmlize);
  20. hook(type => "pagetemplate", id => "comments", call => \&pagetemplate);
  21. hook(type => "cgi", id => "comments", call => \&linkcgi);
  22. IkiWiki::loadplugin("inline");
  23. } # }}}
  24. sub htmlize { # {{{
  25. my %params = @_;
  26. return $params{content};
  27. } # }}}
  28. sub preprocess { # {{{
  29. my %params = @_;
  30. my $page = $params{page};
  31. my $format = $params{format};
  32. if (defined $format && !exists $IkiWiki::hooks{htmlize}{$format}) {
  33. error(sprintf(gettext("unsupported page format %s"), $format));
  34. }
  35. my $content = $params{content};
  36. if (!defined $content) {
  37. error(gettext("comment must have content"));
  38. }
  39. $content =~ s/\\"/"/g;
  40. $content = IkiWiki::filter($page, $params{destpage}, $content);
  41. if ($config{comments_allowdirectives}) {
  42. $content = IkiWiki::preprocess($page, $params{destpage},
  43. $content);
  44. }
  45. # no need to bother with htmlize if it's just HTML
  46. $content = IkiWiki::htmlize($page, $params{destpage}, $format,
  47. $content) if defined $format;
  48. IkiWiki::run_hooks(sanitize => sub {
  49. $content = shift->(
  50. page => $page,
  51. destpage => $params{destpage},
  52. content => $content,
  53. );
  54. });
  55. # override any metadata
  56. if (defined $params{username}) {
  57. my ($authorurl, $author) = linkuser($params{username});
  58. $pagestate{$page}{meta}{author} = $author;
  59. $pagestate{$page}{meta}{authorurl} = $authorurl;
  60. }
  61. elsif (defined $params{ip}) {
  62. $pagestate{$page}{meta}{author} = sprintf(
  63. gettext("Anonymous (IP: %s)"),
  64. $params{ip});
  65. delete $pagestate{$page}{meta}{authorurl};
  66. }
  67. else {
  68. $pagestate{$page}{meta}{author} = gettext("Anonymous");
  69. delete $pagestate{$page}{meta}{authorurl};
  70. }
  71. if (defined $params{subject}) {
  72. $pagestate{$page}{meta}{title} = $params{subject};
  73. }
  74. else {
  75. delete $pagestate{$page}{meta}{title};
  76. }
  77. my $baseurl = urlto($params{destpage}, undef, 1);
  78. my $anchor = "";
  79. my $comments_pagename = $config{comments_pagename};
  80. if ($params{page} =~ m/\/(\Q${comments_pagename}\E\d+)$/) {
  81. $anchor = $1;
  82. }
  83. $pagestate{$page}{meta}{permalink} = "${baseurl}#${anchor}";
  84. eval q{use Date::Parse};
  85. if (! $@) {
  86. my $time = str2time($params{date});
  87. $IkiWiki::pagectime{$page} = $time if defined $time;
  88. }
  89. # FIXME: hard-coded HTML (although it's just to set an ID)
  90. return "<div id=\"$anchor\">$content</div>" if $anchor;
  91. return $content;
  92. } # }}}
  93. sub getsetup () { #{{{
  94. return
  95. plugin => {
  96. safe => 1,
  97. rebuild => 1,
  98. },
  99. # Pages where comments are shown, but new comments are not
  100. # allowed, will show "Comments are closed".
  101. comments_shown_pagespec => {
  102. type => 'pagespec',
  103. example => 'blog/*',
  104. default => '',
  105. description => 'PageSpec for pages where comments will be shown inline',
  106. link => 'ikiwiki/PageSpec',
  107. safe => 1,
  108. rebuild => 1,
  109. },
  110. comments_open_pagespec => {
  111. type => 'pagespec',
  112. example => 'blog/* and created_after(close_old_comments)',
  113. default => '',
  114. description => 'PageSpec for pages where new comments can be posted',
  115. link => 'ikiwiki/PageSpec',
  116. safe => 1,
  117. rebuild => 1,
  118. },
  119. comments_pagename => {
  120. type => 'string',
  121. example => 'comment_',
  122. default => 'comment_',
  123. description => 'Base name for comments, e.g. "comment_" for pages like "sandbox/comment_12"',
  124. safe => 0, # manual page moving will required
  125. rebuild => undef,
  126. },
  127. comments_allowdirectives => {
  128. type => 'boolean',
  129. default => 0,
  130. example => 0,
  131. description => 'Allow directives in newly posted comments?',
  132. safe => 1,
  133. rebuild => 0,
  134. },
  135. comments_commit => {
  136. type => 'boolean',
  137. example => 1,
  138. default => 1,
  139. description => 'commit comments to the VCS',
  140. # old uncommitted comments are likely to cause
  141. # confusion if this is changed
  142. safe => 0,
  143. rebuild => 0,
  144. },
  145. } #}}}
  146. sub checkconfig () { #{{{
  147. $config{comments_commit} = 1 unless defined $config{comments_commit};
  148. $config{comments_pagename} = 'comment_'
  149. unless defined $config{comments_pagename};
  150. } #}}}
  151. # FIXME: logic taken from editpage, should be common code?
  152. sub getcgiuser ($) { # {{{
  153. my $session = shift;
  154. my $user = $session->param('name');
  155. $user = $ENV{REMOTE_ADDR} unless defined $user;
  156. debug("getcgiuser() -> $user");
  157. return $user;
  158. } # }}}
  159. # This is exactly the same as recentchanges_link :-(
  160. sub linkcgi ($) { #{{{
  161. my $cgi=shift;
  162. if (defined $cgi->param('do') && $cgi->param('do') eq "commenter") {
  163. my $page=decode_utf8($cgi->param("page"));
  164. if (!defined $page) {
  165. error("missing page parameter");
  166. }
  167. IkiWiki::loadindex();
  168. my $link=bestlink("", $page);
  169. if (! length $link) {
  170. print "Content-type: text/html\n\n";
  171. print IkiWiki::misctemplate(gettext(gettext("missing page")),
  172. "<p>".
  173. sprintf(gettext("The page %s does not exist."),
  174. htmllink("", "", $page)).
  175. "</p>");
  176. }
  177. else {
  178. IkiWiki::redirect($cgi, urlto($link, undef, 1));
  179. }
  180. exit;
  181. }
  182. }
  183. # FIXME: basically the same logic as recentchanges
  184. # returns (author URL, pretty-printed version)
  185. sub linkuser ($) { # {{{
  186. my $user = shift;
  187. my $oiduser = eval { IkiWiki::openiduser($user) };
  188. if (defined $oiduser) {
  189. return ($user, $oiduser);
  190. }
  191. # FIXME: it'd be good to avoid having such a link for anonymous
  192. # posts
  193. else {
  194. return (IkiWiki::cgiurl(
  195. do => 'commenter',
  196. page => (length $config{userdir}
  197. ? "$config{userdir}/$user"
  198. : "$user")
  199. ), $user);
  200. }
  201. } # }}}
  202. # Mostly cargo-culted from IkiWiki::plugin::editpage
  203. sub sessioncgi ($$) { #{{{
  204. my $cgi=shift;
  205. my $session=shift;
  206. my $do = $cgi->param('do');
  207. return unless $do eq 'comment';
  208. IkiWiki::decode_cgi_utf8($cgi);
  209. eval q{use CGI::FormBuilder};
  210. error($@) if $@;
  211. my @buttons = (POST_COMMENT, PREVIEW, CANCEL);
  212. my $form = CGI::FormBuilder->new(
  213. fields => [qw{do sid page subject editcontent type}],
  214. charset => 'utf-8',
  215. method => 'POST',
  216. required => [qw{editcontent}],
  217. javascript => 0,
  218. params => $cgi,
  219. action => $config{cgiurl},
  220. header => 0,
  221. table => 0,
  222. template => scalar IkiWiki::template_params('comments_form.tmpl'),
  223. # wtf does this do in editpage?
  224. wikiname => $config{wikiname},
  225. );
  226. IkiWiki::decode_form_utf8($form);
  227. IkiWiki::run_hooks(formbuilder_setup => sub {
  228. shift->(title => "comment", form => $form, cgi => $cgi,
  229. session => $session, buttons => \@buttons);
  230. });
  231. IkiWiki::decode_form_utf8($form);
  232. my $type = $form->param('type');
  233. if (defined $type && length $type && $IkiWiki::hooks{htmlize}{$type}) {
  234. $type = possibly_foolish_untaint($type);
  235. }
  236. else {
  237. $type = $config{default_pageext};
  238. }
  239. my @page_types;
  240. if (exists $IkiWiki::hooks{htmlize}) {
  241. @page_types = grep { !/^_/ } keys %{$IkiWiki::hooks{htmlize}};
  242. }
  243. $form->field(name => 'do', type => 'hidden');
  244. $form->field(name => 'sid', type => 'hidden', value => $session->id,
  245. force => 1);
  246. $form->field(name => 'page', type => 'hidden');
  247. $form->field(name => 'subject', type => 'text', size => 72);
  248. $form->field(name => 'editcontent', type => 'textarea', rows => 10);
  249. $form->field(name => "type", value => $type, force => 1,
  250. type => 'select', options => \@page_types);
  251. # The untaint is OK (as in editpage) because we're about to pass
  252. # it to file_pruned anyway
  253. my $page = $form->field('page');
  254. $page = IkiWiki::possibly_foolish_untaint($page);
  255. if (!defined $page || !length $page ||
  256. IkiWiki::file_pruned($page, $config{srcdir})) {
  257. error(gettext("bad page name"));
  258. }
  259. my $allow_directives = $config{comments_allowdirectives};
  260. my $commit_comments = $config{comments_commit};
  261. my $comments_pagename = $config{comments_pagename};
  262. # FIXME: is this right? Or should we be using the candidate subpage
  263. # (whatever that might mean) as the base URL?
  264. my $baseurl = urlto($page, undef, 1);
  265. $form->title(sprintf(gettext("commenting on %s"),
  266. IkiWiki::pagetitle($page)));
  267. $form->tmpl_param('helponformattinglink',
  268. htmllink($page, $page, 'ikiwiki/formatting',
  269. noimageinline => 1,
  270. linktext => 'FormattingHelp'),
  271. allowdirectives => $allow_directives);
  272. if ($form->submitted eq CANCEL) {
  273. # bounce back to the page they wanted to comment on, and exit.
  274. # CANCEL need not be considered in future
  275. IkiWiki::redirect($cgi, urlto($page, undef, 1));
  276. exit;
  277. }
  278. if (not exists $pagesources{$page}) {
  279. error(sprintf(gettext(
  280. "page '%s' doesn't exist, so you can't comment"),
  281. $page));
  282. }
  283. if (not pagespec_match($page, $config{comments_open_pagespec},
  284. location => $page)) {
  285. error(sprintf(gettext(
  286. "comments on page '%s' are closed"),
  287. $page));
  288. }
  289. IkiWiki::check_canedit($page . "[postcomment]", $cgi, $session);
  290. my ($authorurl, $author) = linkuser(getcgiuser($session));
  291. my $editcontent = $form->field('editcontent') || '';
  292. $editcontent =~ s/\r\n/\n/g;
  293. $editcontent =~ s/\r/\n/g;
  294. # FIXME: check that the wiki is locked right now, because
  295. # if it's not, there are mad race conditions!
  296. # FIXME: rather a simplistic way to make the comments...
  297. my $i = 0;
  298. my $file;
  299. my $location;
  300. do {
  301. $i++;
  302. $location = "$page/${comments_pagename}${i}";
  303. } while (-e "$config{srcdir}/$location._comment");
  304. my $anchor = "${comments_pagename}${i}";
  305. $editcontent =~ s/"/\\"/g;
  306. my $content = "[[!comment format=$type\n";
  307. # FIXME: handling of double quotes probably wrong?
  308. if (defined $session->param('name')) {
  309. my $username = $session->param('name');
  310. $username =~ s/"/&quot;/g;
  311. $content .= " username=\"$username\"\n";
  312. }
  313. elsif (defined $ENV{REMOTE_ADDR}) {
  314. my $ip = $ENV{REMOTE_ADDR};
  315. if ($ip =~ m/^([.0-9]+)$/) {
  316. $content .= " ip=\"$1\"\n";
  317. }
  318. }
  319. my $subject = $form->field('subject');
  320. $subject =~ s/"/&quot;/g;
  321. $content .= " subject=\"$subject\"\n";
  322. $content .= " date=\"" . IkiWiki::formattime(time, '%X %x') . "\"\n";
  323. $content .= " content=\"\"\"\n$editcontent\n\"\"\"]]\n";
  324. # This is essentially a simplified version of editpage:
  325. # - the user does not control the page that's created, only the parent
  326. # - it's always a create operation, never an edit
  327. # - this means that conflicts should never happen
  328. # - this means that if they do, rocks fall and everyone dies
  329. if ($form->submitted eq PREVIEW) {
  330. my $preview = IkiWiki::htmlize($location, $page, '_comment',
  331. IkiWiki::linkify($page, $page,
  332. IkiWiki::preprocess($page, $page,
  333. IkiWiki::filter($location,
  334. $page, $content),
  335. 0, 1)));
  336. IkiWiki::run_hooks(format => sub {
  337. $preview = shift->(page => $page,
  338. content => $preview);
  339. });
  340. my $template = template("comments_display.tmpl");
  341. $template->param(content => $preview);
  342. $template->param(title => $form->field('subject'));
  343. $template->param(ctime => displaytime(time));
  344. $template->param(author => $author);
  345. $template->param(authorurl => $authorurl);
  346. $form->tmpl_param(page_preview => $template->output);
  347. }
  348. else {
  349. $form->tmpl_param(page_preview => "");
  350. }
  351. if ($form->submitted eq POST_COMMENT && $form->validate) {
  352. my $file = "$location._comment";
  353. IkiWiki::checksessionexpiry($session, $cgi->param('sid'));
  354. # FIXME: could probably do some sort of graceful retry
  355. # on error? Would require significant unwinding though
  356. writefile($file, $config{srcdir}, $content);
  357. my $conflict;
  358. if ($config{rcs} and $commit_comments) {
  359. my $message = gettext("Added a comment");
  360. if (defined $form->field('subject') &&
  361. length $form->field('subject')) {
  362. $message = sprintf(
  363. gettext("Added a comment: %s"),
  364. $form->field('subject'));
  365. }
  366. IkiWiki::rcs_add($file);
  367. IkiWiki::disable_commit_hook();
  368. $conflict = IkiWiki::rcs_commit_staged($message,
  369. $session->param('name'), $ENV{REMOTE_ADDR});
  370. IkiWiki::enable_commit_hook();
  371. IkiWiki::rcs_update();
  372. }
  373. # Now we need a refresh
  374. require IkiWiki::Render;
  375. IkiWiki::refresh();
  376. IkiWiki::saveindex();
  377. # this should never happen, unless a committer deliberately
  378. # breaks it or something
  379. error($conflict) if defined $conflict;
  380. # Bounce back to where we were, but defeat broken caches
  381. my $anticache = "?updated=$page/${comments_pagename}${i}";
  382. IkiWiki::redirect($cgi, urlto($page, undef, 1).$anticache);
  383. }
  384. else {
  385. IkiWiki::showform ($form, \@buttons, $session, $cgi,
  386. forcebaseurl => $baseurl);
  387. }
  388. exit;
  389. } #}}}
  390. sub pagetemplate (@) { #{{{
  391. my %params = @_;
  392. my $page = $params{page};
  393. my $template = $params{template};
  394. if ($template->query(name => 'comments')) {
  395. my $comments = undef;
  396. my $comments_pagename = $config{comments_pagename};
  397. my $open = 0;
  398. my $shown = pagespec_match($page,
  399. $config{comments_shown_pagespec},
  400. location => $page);
  401. if (pagespec_match($page, "*/${comments_pagename}*",
  402. location => $page)) {
  403. $shown = 0;
  404. $open = 0;
  405. }
  406. if (length $config{cgiurl}) {
  407. $open = pagespec_match($page,
  408. $config{comments_open_pagespec},
  409. location => $page);
  410. }
  411. if ($shown) {
  412. eval q{use IkiWiki::Plugin::inline};
  413. error($@) if $@;
  414. my @args = (
  415. pages => "internal($page/${comments_pagename}*)",
  416. template => 'comments_display',
  417. show => 0,
  418. reverse => 'yes',
  419. page => $page,
  420. destpage => $params{destpage},
  421. );
  422. $comments = IkiWiki::preprocess_inline(@args);
  423. }
  424. if (defined $comments && length $comments) {
  425. $template->param(comments => $comments);
  426. }
  427. if ($open) {
  428. my $commenturl = IkiWiki::cgiurl(do => 'comment',
  429. page => $page);
  430. $template->param(commenturl => $commenturl);
  431. }
  432. }
  433. } # }}}
  434. package IkiWiki::PageSpec;
  435. sub match_postcomment ($$;@) {
  436. my $page = shift;
  437. my $glob = shift;
  438. unless ($page =~ s/\[postcomment\]$//) {
  439. return IkiWiki::FailReason->new("not posting a comment");
  440. }
  441. return match_glob($page, $glob);
  442. }
  443. 1