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