]> sipb.mit.edu Git - ikiwiki.git/blob - IkiWiki/Plugin/attachment.pm
63f0b73c3b330760fd5342657fa691bc421fc155
[ikiwiki.git] / IkiWiki / Plugin / attachment.pm
1 #!/usr/bin/perl
2 package IkiWiki::Plugin::attachment;
3
4 use warnings;
5 use strict;
6 use IkiWiki 2.00;
7
8 sub import { #{{{
9         hook(type => "getsetup", id => "attachment", call => \&getsetup);
10         hook(type => "checkconfig", id => "attachment", call => \&checkconfig);
11         hook(type => "formbuilder_setup", id => "attachment", call => \&formbuilder_setup);
12         hook(type => "formbuilder", id => "attachment", call => \&formbuilder);
13 } # }}}
14
15 sub getsetup () { #{{{
16         return
17                 virus_checker => {
18                         type => "string",
19                         example => "clamdscan -",
20                         description => "virus checker program (reads STDIN, returns nonzero if virus found)",
21                         safe => 0, # executed
22                         rebuild => 0,
23                 },
24                 allowed_attachments => {
25                         type => "pagespec",
26                         example => "mimetype(image/*) and maxsize(50kb)",
27                         description => "enhanced PageSpec specifying what attachments are allowed",
28                         link => "ikiwiki/PageSpec/attachment",
29                         safe => 1,
30                         rebuild => 0,
31                 },
32 } #}}}
33
34 sub check_canattach ($$;$) { #{{{
35         my $session=shift;
36         my $dest=shift; # where it's going to be put, under the srcdir
37         my $file=shift; # the path to the attachment currently
38
39         # Don't allow an attachment to be uploaded with the same name as an
40         # existing page.
41         if (exists $pagesources{$dest} && $pagesources{$dest} ne $dest) {
42                 error(sprintf(gettext("there is already a page named %s"), $dest));
43         }
44
45         # Use a special pagespec to test that the attachment is valid.
46         my $allowed=1;
47         if (defined $config{allowed_attachments} &&
48             length $config{allowed_attachments}) {
49                 $allowed=pagespec_match($dest,
50                         $config{allowed_attachments},
51                         file => $file,
52                         user => $session->param("name"),
53                         ip => $ENV{REMOTE_ADDR},
54                 );
55         }
56
57         # XXX deprecated, should be removed eventually
58         if ($allowed) {
59                 foreach my $admin (@{$config{adminuser}}) {
60                         my $allowed_attachments=IkiWiki::userinfo_get($admin, "allowed_attachments");
61                         if (defined $allowed_attachments &&
62                             length $allowed_attachments) {
63                                 $allowed=pagespec_match($dest,
64                                         $allowed_attachments,
65                                         file => $file,
66                                         user => $session->param("name"),
67                                         ip => $ENV{REMOTE_ADDR},
68                                 );
69                                 last if $allowed;
70                         }
71                 }
72         }
73
74         if (! $allowed) {
75                 error(gettext("prohibited by allowed_attachments")." ($allowed)");
76         }
77         else {
78                 return 1;
79         }
80 } #}}}
81
82 sub checkconfig () { #{{{
83         $config{cgi_disable_uploads}=0;
84 } #}}}
85
86 sub formbuilder_setup (@) { #{{{
87         my %params=@_;
88         my $form=$params{form};
89         my $q=$params{cgi};
90
91         if (defined $form->field("do") && $form->field("do") eq "edit") {
92                 # Add attachment field, set type to multipart.
93                 $form->enctype(&CGI::MULTIPART);
94                 $form->field(name => 'attachment', type => 'file');
95                 # These buttons are not put in the usual place, so
96                 # are not added to the normal formbuilder button list.
97                 $form->tmpl_param("field-upload" => '<input name="_submit" type="submit" value="Upload Attachment" />');
98                 $form->tmpl_param("field-link" => '<input name="_submit" type="submit" value="Insert Links" />');
99
100                 # Add the javascript from the toggle plugin;
101                 # the attachments interface uses it to toggle visibility.
102                 require IkiWiki::Plugin::toggle;
103                 $form->tmpl_param("javascript" => $IkiWiki::Plugin::toggle::javascript);
104                 # Start with the attachments interface toggled invisible,
105                 # but if it was used, keep it open.
106                 if ($form->submitted ne "Upload Attachment" &&
107                     (! defined $q->param("attachment_select") ||
108                     ! length $q->param("attachment_select"))) {
109                         $form->tmpl_param("attachments-class" => "toggleable");
110                 }
111                 else {
112                         $form->tmpl_param("attachments-class" => "toggleable-open");
113                 }
114         }
115         elsif ($form->title eq "preferences") {
116                 # XXX deprecated, should remove eventually
117                 my $session=$params{session};
118                 my $user_name=$session->param("name");
119
120                 $form->field(name => "allowed_attachments", size => 50,
121                         fieldset => "admin",
122                         comment => "deprecated; please move to allowed_attachments in setup file",
123                 );
124                 if (! IkiWiki::is_admin($user_name)) {
125                         $form->field(name => "allowed_attachments", type => "hidden");
126                 }
127                 if (! $form->submitted) {
128                         my $value=IkiWiki::userinfo_get($user_name, "allowed_attachments");
129                         if (length $value) {
130                                 $form->field(name => "allowed_attachments", force => 1,
131                                         value => IkiWiki::userinfo_get($user_name, "allowed_attachments"));
132                         }
133                         else {
134                                 $form->field(name => "allowed_attachments", type => "hidden");
135                         }
136                 }
137                 if ($form->submitted && $form->submitted eq 'Save Preferences') {
138                         if (defined $form->field("allowed_attachments")) {
139                                 IkiWiki::userinfo_set($user_name, "allowed_attachments",
140                                 $form->field("allowed_attachments")) ||
141                                         error("failed to set allowed_attachments");
142                                 if (length $form->field("allowed_attachments")) {
143                                         $form->field(name => "allowed_attachments", type => "hidden");
144                                 }
145                         }
146                 }
147         }
148 } #}}}
149
150 sub formbuilder (@) { #{{{
151         my %params=@_;
152         my $form=$params{form};
153         my $q=$params{cgi};
154
155         return if ! defined $form->field("do") || $form->field("do") ne "edit";
156
157         my $filename=$q->param('attachment');
158         if (defined $filename && length $filename &&
159             ($form->submitted eq "Upload Attachment" || $form->submitted eq "Save Page")) {
160                 my $session=$params{session};
161                 
162                 # This is an (apparently undocumented) way to get the name
163                 # of the temp file that CGI writes the upload to.
164                 my $tempfile=$q->tmpFileName($filename);
165                 if (! defined $tempfile || ! length $tempfile) {
166                         # perl 5.8 needs an alternative, awful method
167                         if ($q =~ /HASH/ && exists $q->{'.tmpfiles'}) {
168                                 foreach my $key (keys(%{$q->{'.tmpfiles'}})) {
169                                         $tempfile=$q->tmpFileName(\$key);
170                                         last if defined $tempfile && length $tempfile;
171                                 }
172                         }
173                         if (! defined $tempfile || ! length $tempfile) {
174                                 error("CGI::tmpFileName failed to return the uploaded file name");
175                         }
176                 }
177
178                 $filename=IkiWiki::linkpage(
179                         IkiWiki::possibly_foolish_untaint(
180                                 attachment_location($form->field('page')).
181                                 IkiWiki::basename($filename)));
182                 if (IkiWiki::file_pruned($filename, $config{srcdir})) {
183                         error(gettext("bad attachment filename"));
184                 }
185                 
186                 # Check that the user is allowed to edit a page with the
187                 # name of the attachment.
188                 IkiWiki::check_canedit($filename, $q, $session, 1);
189                 # And that the attachment itself is acceptable.
190                 check_canattach($session, $filename, $tempfile);
191
192                 # Needed for fast_file_copy and for rendering below.
193                 require IkiWiki::Render;
194
195                 # Move the attachment into place.
196                 # Try to use a fast rename; fall back to copying.
197                 IkiWiki::prep_writefile($filename, $config{srcdir});
198                 unlink($config{srcdir}."/".$filename);
199                 if (rename($tempfile, $config{srcdir}."/".$filename)) {
200                         # The temp file has tight permissions; loosen up.
201                         chmod(0666 & ~umask, $config{srcdir}."/".$filename);
202                 }
203                 else {
204                         my $fh=$q->upload('attachment');
205                         if (! defined $fh || ! ref $fh) {
206                                 # needed by old CGI versions
207                                 $fh=$q->param('attachment');
208                                 if (! defined $fh || ! ref $fh) {
209                                         # even that doesn't always work,
210                                         # fall back to opening the tempfile
211                                         $fh=undef;
212                                         open($fh, "<", $tempfile) || error("failed to open \"$tempfile\": $!");
213                                 }
214                         }
215                         binmode($fh);
216                         writefile($filename, $config{srcdir}, undef, 1, sub {
217                                 IkiWiki::fast_file_copy($tempfile, $filename, $fh, @_);
218                         });
219                 }
220
221                 # Check the attachment in and trigger a wiki refresh.
222                 if ($config{rcs}) {
223                         IkiWiki::rcs_add($filename);
224                         IkiWiki::disable_commit_hook();
225                         IkiWiki::rcs_commit($filename, gettext("attachment upload"),
226                                 IkiWiki::rcs_prepedit($filename),
227                                 $session->param("name"), $ENV{REMOTE_ADDR});
228                         IkiWiki::enable_commit_hook();
229                         IkiWiki::rcs_update();
230                 }
231                 IkiWiki::refresh();
232                 IkiWiki::saveindex();
233         }
234         elsif ($form->submitted eq "Insert Links") {
235                 my $page=quotemeta($q->param("page"));
236                 my $add="";
237                 foreach my $f ($q->param("attachment_select")) {
238                         $f=~s/^$page\///;
239                         $add.="[[$f]]\n";
240                 }
241                 $form->field(name => 'editcontent',
242                         value => $form->field('editcontent')."\n\n".$add,
243                         force => 1) if length $add;
244         }
245         
246         # Generate the attachment list only after having added any new
247         # attachments.
248         $form->tmpl_param("attachment_list" => [attachment_list($form->field('page'))]);
249 } # }}}
250
251 sub attachment_location ($) { #{{{
252         my $page=shift;
253         
254         # Put the attachment in a subdir of the page it's attached
255         # to, unless that page is an "index" page.
256         $page=~s/(^|\/)index//;
257         $page.="/" if length $page;
258         
259         return $page;
260 } #}}}
261
262 sub attachment_list ($) { #{{{
263         my $page=shift;
264         my $loc=attachment_location($page);
265
266         my @ret;
267         foreach my $f (values %pagesources) {
268                 if (! defined IkiWiki::pagetype($f) &&
269                     $f=~m/^\Q$loc\E[^\/]+$/ &&
270                     -e "$config{srcdir}/$f") {
271                         push @ret, {
272                                 "field-select" => '<input type="checkbox" name="attachment_select" value="'.$f.'" />',
273                                 link => htmllink($page, $page, $f, noimageinline => 1),
274                                 size => humansize((stat(_))[7]),
275                                 mtime => displaytime($IkiWiki::pagemtime{$f}),
276                                 mtime_raw => $IkiWiki::pagemtime{$f},
277                         };
278                 }
279         }
280
281         # Sort newer attachments to the top of the list, so a newly-added
282         # attachment appears just before the form used to add it.
283         return sort { $b->{mtime_raw} <=> $a->{mtime_raw} || $a->{link} cmp $b->{link} } @ret;
284 } #}}}
285
286 my %units=( #{{{        # size in bytes
287         B               => 1,
288         byte            => 1,
289         KB              => 2 ** 10,
290         kilobyte        => 2 ** 10,
291         K               => 2 ** 10,
292         KB              => 2 ** 10,
293         kilobyte        => 2 ** 10,
294         M               => 2 ** 20,
295         MB              => 2 ** 20,
296         megabyte        => 2 ** 20,
297         G               => 2 ** 30,
298         GB              => 2 ** 30,
299         gigabyte        => 2 ** 30,
300         T               => 2 ** 40,
301         TB              => 2 ** 40,
302         terabyte        => 2 ** 40,
303         P               => 2 ** 50,
304         PB              => 2 ** 50,
305         petabyte        => 2 ** 50,
306         E               => 2 ** 60,
307         EB              => 2 ** 60,
308         exabyte         => 2 ** 60,
309         Z               => 2 ** 70,
310         ZB              => 2 ** 70,
311         zettabyte       => 2 ** 70,
312         Y               => 2 ** 80,
313         YB              => 2 ** 80,
314         yottabyte       => 2 ** 80,
315         # ikiwiki, if you find you need larger data quantities, either modify
316         # yourself to add them, or travel back in time to 2008 and kill me.
317         #   -- Joey
318 ); #}}}
319
320 sub parsesize ($) { #{{{
321         my $size=shift;
322
323         no warnings;
324         my $base=$size+0; # force to number
325         use warnings;
326         foreach my $unit (sort keys %units) {
327                 if ($size=~/[0-9\s]\Q$unit\E$/i) {
328                         return $base * $units{$unit};
329                 }
330         }
331         return $base;
332 } #}}}
333
334 sub humansize ($) { #{{{
335         my $size=shift;
336
337         foreach my $unit (reverse sort { $units{$a} <=> $units{$b} || $b cmp $a } keys %units) {
338                 if ($size / $units{$unit} > 0.25) {
339                         return (int($size / $units{$unit} * 10)/10).$unit;
340                 }
341         }
342         return $size; # near zero, or negative
343 } #}}}
344
345 package IkiWiki::PageSpec;
346
347 sub match_maxsize ($$;@) { #{{{
348         shift;
349         my $maxsize=eval{IkiWiki::Plugin::attachment::parsesize(shift)};
350         if ($@) {
351                 return IkiWiki::FailReason->new("unable to parse maxsize (or number too large)");
352         }
353
354         my %params=@_;
355         if (! exists $params{file}) {
356                 return IkiWiki::FailReason->new("no file specified");
357         }
358
359         if (-s $params{file} > $maxsize) {
360                 return IkiWiki::FailReason->new("file too large (".(-s $params{file})." >  $maxsize)");
361         }
362         else {
363                 return IkiWiki::SuccessReason->new("file not too large");
364         }
365 } #}}}
366
367 sub match_minsize ($$;@) { #{{{
368         shift;
369         my $minsize=eval{IkiWiki::Plugin::attachment::parsesize(shift)};
370         if ($@) {
371                 return IkiWiki::FailReason->new("unable to parse minsize (or number too large)");
372         }
373
374         my %params=@_;
375         if (! exists $params{file}) {
376                 return IkiWiki::FailReason->new("no file specified");
377         }
378
379         if (-s $params{file} < $minsize) {
380                 return IkiWiki::FailReason->new("file too small");
381         }
382         else {
383                 return IkiWiki::SuccessReason->new("file not too small");
384         }
385 } #}}}
386
387 sub match_mimetype ($$;@) { #{{{
388         shift;
389         my $wanted=shift;
390
391         my %params=@_;
392         if (! exists $params{file}) {
393                 return IkiWiki::FailReason->new("no file specified");
394         }
395
396         # Use ::magic to get the mime type, the idea is to only trust
397         # data obtained by examining the actual file contents.
398         eval q{use File::MimeInfo::Magic};
399         if ($@) {
400                 return IkiWiki::FailReason->new("failed to load File::MimeInfo::Magic ($@); cannot check MIME type");
401         }
402         my $mimetype=File::MimeInfo::Magic::magic($params{file});
403         if (! defined $mimetype) {
404                 $mimetype="unknown";
405         }
406
407         my $regexp=IkiWiki::glob2re($wanted);
408         if ($mimetype!~/^$regexp$/i) {
409                 return IkiWiki::FailReason->new("file MIME type is $mimetype, not $wanted");
410         }
411         else {
412                 return IkiWiki::SuccessReason->new("file MIME type is $mimetype");
413         }
414 } #}}}
415
416 sub match_virusfree ($$;@) { #{{{
417         shift;
418         my $wanted=shift;
419
420         my %params=@_;
421         if (! exists $params{file}) {
422                 return IkiWiki::FailReason->new("no file specified");
423         }
424
425         if (! exists $IkiWiki::config{virus_checker} ||
426             ! length $IkiWiki::config{virus_checker}) {
427                 return IkiWiki::FailReason->new("no virus_checker configured");
428         }
429
430         # The file needs to be fed into the virus checker on stdin,
431         # because the file is not world-readable, and if clamdscan is
432         # used, clamd would fail to read it.
433         eval q{use IPC::Open2};
434         error($@) if $@;
435         open (IN, "<", $params{file}) || return IkiWiki::FailReason->new("failed to read file");
436         binmode(IN);
437         my $sigpipe=0;
438         $SIG{PIPE} = sub { $sigpipe=1 };
439         my $pid=open2(\*CHECKER_OUT, "<&IN", $IkiWiki::config{virus_checker}); 
440         my $reason=<CHECKER_OUT>;
441         chomp $reason;
442         1 while (<CHECKER_OUT>);
443         close(CHECKER_OUT);
444         waitpid $pid, 0;
445         $SIG{PIPE}="DEFAULT";
446         if ($sigpipe || $?) {
447                 if (! length $reason) {
448                         $reason="virus checker $IkiWiki::config{virus_checker}; failed with no output";
449                 }
450                 return IkiWiki::FailReason->new("file seems to contain a virus ($reason)");
451         }
452         else {
453                 return IkiWiki::SuccessReason->new("file seems virusfree ($reason)");
454         }
455 } #}}}
456
457 sub match_ispage ($$;@) { #{{{
458         my $filename=shift;
459
460         if (defined IkiWiki::pagetype($filename)) {
461                 return IkiWiki::SuccessReason->new("file is a wiki page");
462         }
463         else {
464                 return IkiWiki::FailReason->new("file is not a wiki page");
465         }
466 } #}}}
467
468 sub match_user ($$;@) { #{{{
469         shift;
470         my $user=shift;
471         my %params=@_;
472         
473         if (! exists $params{user}) {
474                 return IkiWiki::FailReason->new("no user specified");
475         }
476
477         if (defined $params{user} && lc $params{user} eq lc $user) {
478                 return IkiWiki::SuccessReason->new("user is $user");
479         }
480         elsif (! defined $params{user}) {
481                 return IkiWiki::FailReason->new("not logged in");
482         }
483         else {
484                 return IkiWiki::FailReason->new("user is $params{user}, not $user");
485         }
486 } #}}}
487
488 sub match_ip ($$;@) { #{{{
489         shift;
490         my $ip=shift;
491         my %params=@_;
492         
493         if (! exists $params{ip}) {
494                 return IkiWiki::FailReason->new("no IP specified");
495         }
496
497         if (defined $params{ip} && lc $params{ip} eq lc $ip) {
498                 return IkiWiki::SuccessReason->new("IP is $ip");
499         }
500         else {
501                 return IkiWiki::FailReason->new("IP is $params{ip}, not $ip");
502         }
503 } #}}}
504
505 1