summaryrefslogtreecommitdiff
path: root/ikiwiki
blob: 8ba3249616a556e652f57a4ef86607926f4609af (plain)
  1. #!/usr/bin/perl -T
  2. use warnings;
  3. use strict;
  4. use File::Find;
  5. use Memoize;
  6. use File::Spec;
  7. $ENV{PATH}="/usr/local/bin:/usr/bin:/bin";
  8. BEGIN {
  9. $blosxom::version="is a proper perl module too much to ask?";
  10. do "/usr/bin/markdown";
  11. }
  12. my ($srcdir, $destdir, %links, %oldlinks, %oldpagemtime, %renderedfiles,
  13. %pagesources);
  14. my $wiki_link_regexp=qr/\[\[([^\s]+)\]\]/;
  15. my $wiki_file_regexp=qr/(^[-A-Za-z0-9_.:\/+]+$)/;
  16. my $wiki_file_prune_regexp=qr!((^|/).svn/|\.\.)!;
  17. my $verbose=0;
  18. my $wikiname="wiki";
  19. my $default_pagetype=".mdwn";
  20. my $cgi=0;
  21. my $url="";
  22. sub usage {
  23. die "usage: ikiwiki [options] source dest\n";
  24. }
  25. sub error ($) {
  26. if ($cgi) {
  27. print "Content-type: text/html\n\n";
  28. print "Error: @_\n";
  29. exit 1;
  30. }
  31. else {
  32. die @_;
  33. }
  34. }
  35. sub debug ($) {
  36. print "@_\n" if $verbose;
  37. }
  38. sub mtime ($) {
  39. my $page=shift;
  40. return (stat($page))[9];
  41. }
  42. sub possibly_foolish_untaint ($) {
  43. my $tainted=shift;
  44. my ($untainted)=$tainted=~/(.*)/;
  45. return $untainted;
  46. }
  47. sub basename {
  48. my $file=shift;
  49. $file=~s!.*/!!;
  50. return $file;
  51. }
  52. sub dirname {
  53. my $file=shift;
  54. $file=~s!/?[^/]+$!!;
  55. return $file;
  56. }
  57. sub pagetype ($) {
  58. my $page=shift;
  59. if ($page =~ /\.mdwn$/) {
  60. return ".mdwn";
  61. }
  62. else {
  63. return "unknown";
  64. }
  65. }
  66. sub pagename ($) {
  67. my $file=shift;
  68. my $type=pagetype($file);
  69. my $page=$file;
  70. $page=~s/\Q$type\E*$// unless $type eq 'unknown';
  71. return $page;
  72. }
  73. sub htmlpage ($) {
  74. my $page=shift;
  75. return $page.".html";
  76. }
  77. sub readfile ($) {
  78. my $file=shift;
  79. local $/=undef;
  80. open (IN, "$file") || error("failed to read $file: $!");
  81. my $ret=<IN>;
  82. close IN;
  83. return $ret;
  84. }
  85. sub writefile ($$) {
  86. my $file=shift;
  87. my $content=shift;
  88. my $dir=dirname($file);
  89. if (! -d $dir) {
  90. my $d="";
  91. foreach my $s (split(m!/+!, $dir)) {
  92. $d.="$s/";
  93. if (! -d $d) {
  94. mkdir($d) || error("failed to create directory $d: $!");
  95. }
  96. }
  97. }
  98. open (OUT, ">$file") || error("failed to write $file: $!");
  99. print OUT $content;
  100. close OUT;
  101. }
  102. sub findlinks {
  103. my $content=shift;
  104. my @links;
  105. while ($content =~ /$wiki_link_regexp/g) {
  106. push @links, lc($1);
  107. }
  108. return @links;
  109. }
  110. # Given a page and the text of a link on the page, determine which existing
  111. # page that link best points to. Prefers pages under a subdirectory with
  112. # the same name as the source page, failing that goes down the directory tree
  113. # to the base looking for matching pages.
  114. sub bestlink ($$) {
  115. my $page=shift;
  116. my $link=lc(shift);
  117. my $cwd=$page;
  118. do {
  119. my $l=$cwd;
  120. $l.="/" if length $l;
  121. $l.=$link;
  122. if (exists $links{$l}) {
  123. #debug("for $page, \"$link\", use $l");
  124. return $l;
  125. }
  126. } while $cwd=~s!/?[^/]+$!!;
  127. #print STDERR "warning: page $page, broken link: $link\n";
  128. return "";
  129. }
  130. sub isinlinableimage ($) {
  131. my $file=shift;
  132. $file=~/\.(png|gif|jpg|jpeg)$/;
  133. }
  134. sub htmllink ($$) {
  135. my $page=shift;
  136. my $link=shift;
  137. my $bestlink=bestlink($page, $link);
  138. return $link if $page eq $bestlink;
  139. # TODO BUG: %renderedfiles may not have it, if the linked to page
  140. # was also added and isn't yet rendered! Note that this bug is
  141. # masked by the bug mentioned below that makes all new files
  142. # be rendered twice.
  143. if (! grep { $_ eq $bestlink } values %renderedfiles) {
  144. $bestlink=htmlpage($bestlink);
  145. }
  146. if (! grep { $_ eq $bestlink } values %renderedfiles) {
  147. return "<a href=\"?\">?</a>$link"
  148. }
  149. $bestlink=File::Spec->abs2rel($bestlink, dirname($page));
  150. if (isinlinableimage($bestlink)) {
  151. return "<img src=\"$bestlink\">";
  152. }
  153. return "<a href=\"$bestlink\">$link</a>";
  154. }
  155. sub linkify ($$) {
  156. my $content=shift;
  157. my $file=shift;
  158. $content =~ s/$wiki_link_regexp/htmllink(pagename($file), $1)/eg;
  159. return $content;
  160. }
  161. sub htmlize ($$) {
  162. my $type=shift;
  163. my $content=shift;
  164. if ($type eq '.mdwn') {
  165. return Markdown::Markdown($content);
  166. }
  167. else {
  168. error("htmlization of $type not supported");
  169. }
  170. }
  171. sub linkbacks ($$) {
  172. my $content=shift;
  173. my $page=shift;
  174. my @links;
  175. foreach my $p (keys %links) {
  176. next if bestlink($page, $p) eq $page;
  177. if (grep { length $_ && bestlink($p, $_) eq $page } @{$links{$p}}) {
  178. my $href=File::Spec->abs2rel(htmlpage($p), dirname($page));
  179. # Trim common dir prefixes from both pages.
  180. my $p_trimmed=$p;
  181. my $page_trimmed=$page;
  182. my $dir;
  183. 1 while (($dir)=$page_trimmed=~m!^([^/]+/)!) &&
  184. defined $dir &&
  185. $p_trimmed=~s/^\Q$dir\E// &&
  186. $page_trimmed=~s/^\Q$dir\E//;
  187. push @links, "<a href=\"$href\">$p_trimmed</a>";
  188. }
  189. }
  190. $content.="<hr><p>Links: ".join(" ", sort @links)."</p>\n" if @links;
  191. return $content;
  192. }
  193. sub finalize ($$) {
  194. my $content=shift;
  195. my $page=shift;
  196. my $title=basename($page);
  197. $title=~s/_/ /g;
  198. my $pagelink="";
  199. my $path="";
  200. foreach my $dir (reverse split("/", $page)) {
  201. if (length($pagelink)) {
  202. $pagelink="<a href=\"$path$dir.html\">$dir</a>/ $pagelink";
  203. }
  204. else {
  205. $pagelink=$dir;
  206. }
  207. $path.="../";
  208. }
  209. $path=~s/\.\.\/$/index.html/;
  210. $pagelink="<a href=\"$path\">$wikiname</a>/ $pagelink";
  211. $content="<html>\n<head><title>$title</title></head>\n<body>\n".
  212. "<h1>$pagelink</h1>\n".
  213. $content.
  214. "</body>\n</html>\n";
  215. return $content;
  216. }
  217. sub render ($) {
  218. my $file=shift;
  219. my $type=pagetype($file);
  220. my $content=readfile("$srcdir/$file");
  221. if ($type ne 'unknown') {
  222. my $page=pagename($file);
  223. $links{$page}=[findlinks($content)];
  224. $content=linkify($content, $file);
  225. $content=htmlize($type, $content);
  226. $content=linkbacks($content, $page);
  227. $content=finalize($content, $page);
  228. writefile("$destdir/".htmlpage($page), $content);
  229. $oldpagemtime{$page}=time;
  230. $renderedfiles{$page}=htmlpage($page);
  231. }
  232. else {
  233. $links{$file}=[];
  234. writefile("$destdir/$file", $content);
  235. $oldpagemtime{$file}=time;
  236. $renderedfiles{$file}=$file;
  237. }
  238. }
  239. sub loadindex () {
  240. open (IN, "$srcdir/.index") || return;
  241. while (<IN>) {
  242. $_=possibly_foolish_untaint($_);
  243. chomp;
  244. my ($mtime, $file, $rendered, @links)=split(' ', $_);
  245. my $page=pagename($file);
  246. $pagesources{$page}=$file;
  247. $oldpagemtime{$page}=$mtime;
  248. $oldlinks{$page}=[@links];
  249. $links{$page}=[@links];
  250. $renderedfiles{$page}=$rendered;
  251. }
  252. close IN;
  253. }
  254. sub saveindex () {
  255. open (OUT, ">$srcdir/.index") || error("cannot write to .index: $!");
  256. foreach my $page (keys %oldpagemtime) {
  257. print OUT "$oldpagemtime{$page} $pagesources{$page} $renderedfiles{$page} ".
  258. join(" ", @{$links{$page}})."\n"
  259. if $oldpagemtime{$page};
  260. }
  261. close OUT;
  262. }
  263. sub update () {
  264. if (-d "$srcdir/.svn") {
  265. if (system("svn", "update", "--quiet", $srcdir) != 0) {
  266. warn("svn update failed\n");
  267. }
  268. }
  269. }
  270. sub prune ($) {
  271. my $file=shift;
  272. unlink($file);
  273. my $dir=dirname($file);
  274. while (rmdir($dir)) {
  275. $dir=dirname($dir);
  276. }
  277. }
  278. sub refresh () {
  279. # Find existing pages.
  280. my %exists;
  281. my @files;
  282. find({
  283. no_chdir => 1,
  284. wanted => sub {
  285. if (/$wiki_file_prune_regexp/) {
  286. $File::Find::prune=1;
  287. }
  288. elsif (! -d $_ && ! /\.html$/ && ! /\/\./) {
  289. my ($f)=/$wiki_file_regexp/; # untaint
  290. if (! defined $f) {
  291. warn("skipping bad filename $_\n");
  292. }
  293. else {
  294. $f=~s/^\Q$srcdir\E\/?//;
  295. push @files, $f;
  296. $exists{pagename($f)}=1;
  297. }
  298. }
  299. },
  300. }, $srcdir);
  301. my %rendered;
  302. # check for added or removed pages
  303. my @add;
  304. foreach my $file (@files) {
  305. my $page=pagename($file);
  306. if (! $oldpagemtime{$page}) {
  307. debug("new page $page");
  308. push @add, $file;
  309. $links{$page}=[];
  310. $pagesources{$page}=$file;
  311. }
  312. }
  313. my @del;
  314. foreach my $page (keys %oldpagemtime) {
  315. if (! $exists{$page}) {
  316. debug("removing old page $page");
  317. push @del, $renderedfiles{$page};
  318. prune($destdir."/".$renderedfiles{$page});
  319. delete $renderedfiles{$page};
  320. $oldpagemtime{$page}=0;
  321. delete $pagesources{$page};
  322. }
  323. }
  324. # render any updated files
  325. foreach my $file (@files) {
  326. my $page=pagename($file);
  327. if (! exists $oldpagemtime{$page} ||
  328. mtime("$srcdir/$file") > $oldpagemtime{$page}) {
  329. debug("rendering changed file $file");
  330. render($file);
  331. $rendered{$file}=1;
  332. }
  333. }
  334. # if any files were added or removed, check to see if each page
  335. # needs an update due to linking to them
  336. # TODO: inefficient; pages may get rendered above and again here;
  337. # problem is the bestlink may have changed and we won't know until
  338. # now
  339. if (@add || @del) {
  340. FILE: foreach my $file (@files) {
  341. my $page=pagename($file);
  342. foreach my $f (@add, @del) {
  343. my $p=pagename($f);
  344. foreach my $link (@{$links{$page}}) {
  345. if (bestlink($page, $link) eq $p) {
  346. debug("rendering $file, which links to $p");
  347. render($file);
  348. $rendered{$file}=1;
  349. next FILE;
  350. }
  351. }
  352. }
  353. }
  354. }
  355. # handle linkbacks; if a page has added/removed links, update the
  356. # pages it links to
  357. # TODO: inefficient; pages may get rendered above and again here;
  358. # problem is the linkbacks could be wrong in the first pass render
  359. # above
  360. if (%rendered) {
  361. my %linkchanged;
  362. foreach my $file (keys %rendered, @del) {
  363. my $page=pagename($file);
  364. if (exists $links{$page}) {
  365. foreach my $link (@{$links{$page}}) {
  366. $link=bestlink($page, $link);
  367. if (length $link &&
  368. ! exists $oldlinks{$page} ||
  369. ! grep { $_ eq $link } @{$oldlinks{$page}}) {
  370. $linkchanged{$link}=1;
  371. }
  372. }
  373. }
  374. if (exists $oldlinks{$page}) {
  375. foreach my $link (@{$oldlinks{$page}}) {
  376. $link=bestlink($page, $link);
  377. if (length $link &&
  378. ! exists $links{$page} ||
  379. ! grep { $_ eq $link } @{$links{$page}}) {
  380. $linkchanged{$link}=1;
  381. }
  382. }
  383. }
  384. }
  385. foreach my $link (keys %linkchanged) {
  386. my $linkfile=$pagesources{$link};
  387. if (defined $linkfile) {
  388. debug("rendering $linkfile, to update its linkbacks");
  389. render($linkfile);
  390. }
  391. }
  392. }
  393. }
  394. # Generates a C wrapper program for running ikiwiki in a specific way.
  395. # The wrapper may be safely made suid.
  396. sub gen_wrapper ($$) {
  397. my ($offline, $rebuild)=@_;
  398. eval {use Cwd 'abs_path'};
  399. $srcdir=abs_path($srcdir);
  400. $destdir=abs_path($destdir);
  401. my $this=abs_path($0);
  402. if (! -x $this) {
  403. error("$this doesn't seem to be executable");
  404. }
  405. my $call=qq{"$this", "$this", "$srcdir", "$destdir", "--wikiname=$wikiname"};
  406. $call.=', "--verbose"' if $verbose;
  407. $call.=', "--rebuild"' if $rebuild;
  408. $call.=', "--offline"' if $offline;
  409. $call.=', "--cgi"' if $cgi;
  410. $call.=', "--url='.$url.'"' if $url;
  411. # For CGI we need all these environment variables.
  412. my @envsave=qw{REMOTE_ADDR QUERY_STRING REQUEST_METHOD REQUEST_URI
  413. CONTENT_TYPE CONTENT_LENGTH GATEWAY_INTERFACE};
  414. my $envsave="";
  415. foreach my $var (@envsave) {
  416. $envsave.=<<"EOF"
  417. if ((s=getenv("$var")))
  418. asprintf(&newenviron[i++], "%s=%s", "$var", s);
  419. EOF
  420. }
  421. open(OUT, ">ikiwiki-wrap.c") || error("failed to write ikiwiki-wrap.c: $!");;
  422. print OUT <<"EOF";
  423. /* A wrapper for ikiwiki, can be safely made suid. */
  424. #define _GNU_SOURCE
  425. #include <stdio.h>
  426. #include <unistd.h>
  427. #include <stdlib.h>
  428. #include <string.h>
  429. extern char **environ;
  430. int main (void) {
  431. /* Sanitize environment. */
  432. if ($cgi) {
  433. char *s;
  434. char *newenviron[$#envsave+2];
  435. int i=0;
  436. $envsave;
  437. newenviron[i]=NULL;
  438. environ=newenviron;
  439. }
  440. else {
  441. clearenv();
  442. }
  443. execl($call, NULL);
  444. perror("failed to run $this");
  445. exit(1);
  446. }
  447. EOF
  448. close OUT;
  449. if (system("gcc", "ikiwiki-wrap.c", "-o", "ikiwiki-wrap") != 0) {
  450. error("failed to compile ikiwiki-wrap.c");
  451. }
  452. unlink("ikiwiki-wrap.c");
  453. print "successfully generated ikiwiki-wrap\n";
  454. exit 0;
  455. }
  456. sub cgi () {
  457. eval q{use CGI};
  458. my $q=CGI->new;
  459. my $do=$q->param('do');
  460. if (! defined $do || ! length $do) {
  461. error("\"do\" parameter missing");
  462. }
  463. my ($page)=$q->param('page')=~/$wiki_file_regexp/; # untaint
  464. if (! defined $page || ! length $page || $page ne $q->param('page') ||
  465. $page=~/$wiki_file_prune_regexp/ || $page=~/^\//) {
  466. error("bad page name");
  467. }
  468. my $action=$q->request_uri;
  469. $action=~s/\?.*//;
  470. if ($do eq 'edit') {
  471. my $content="";
  472. if (exists $pagesources{lc($page)}) {
  473. $content=readfile("$srcdir/$pagesources{lc($page)}");
  474. $content=~s/\n/\r\n/g;
  475. }
  476. $q->param("do", "save");
  477. print $q->header,
  478. $q->start_html("$wikiname: Editing $page"),
  479. $q->h1("$wikiname: Editing $page"),
  480. $q->start_form(-action => $action),
  481. $q->hidden('do'),
  482. $q->hidden('page'),
  483. $q->textarea(-name => 'content',
  484. -default => $content,
  485. -rows => 20,
  486. -columns => 80),
  487. $q->p,
  488. $q->submit("Save Changes"),
  489. # TODO: Cancel button returns to page.
  490. # TODO: Preview button.
  491. # TODO: Commit message field.
  492. # TODO: Conflict prevention.
  493. $q->end_form,
  494. $q->end_html;
  495. }
  496. elsif ($do eq 'save') {
  497. my $file=$page.$default_pagetype;
  498. if (exists $pagesources{lc($page)}) {
  499. $file=$pagesources{lc($page)};
  500. }
  501. my $content=$q->param('content');
  502. $content=~s/\r\n/\n/g;
  503. $content=~s/\r/\n/g;
  504. writefile("$srcdir/$file", $content);
  505. print $q->redirect("$url/".htmlpage($page));
  506. }
  507. else {
  508. error("unknown do parameter");
  509. }
  510. }
  511. my $rebuild=0;
  512. my $offline=0;
  513. my $wrapper=0;
  514. if (grep /^-/, @ARGV) {
  515. eval {use Getopt::Long};
  516. GetOptions(
  517. "wikiname=s" => \$wikiname,
  518. "verbose|v" => \$verbose,
  519. "rebuild" => \$rebuild,
  520. "wrapper" => \$wrapper,
  521. "offline" => \$offline,
  522. "cgi" => \$cgi,
  523. "url=s" => \$url,
  524. ) || usage();
  525. }
  526. usage() unless @ARGV == 2;
  527. ($srcdir) = possibly_foolish_untaint(shift);
  528. ($destdir) = possibly_foolish_untaint(shift);
  529. if ($cgi && ! length $url) {
  530. error("Must specify url to wiki with --url when using --cgi");
  531. }
  532. gen_wrapper($offline, $rebuild) if $wrapper;
  533. memoize('pagename');
  534. memoize('bestlink');
  535. loadindex() unless $rebuild;
  536. if ($cgi) {
  537. cgi();
  538. }
  539. else {
  540. update() unless $offline;
  541. refresh();
  542. saveindex();
  543. }