]> git.donarmstrong.com Git - debbugs.git/blob - Debbugs/Libravatar.pm
Libravatar: Refactor cache handling to avoid unncessary stat
[debbugs.git] / Debbugs / Libravatar.pm
1 # This module is part of debbugs, and is released
2 # under the terms of the GPL version 2, or any later version. See the
3 # file README and COPYING for more information.
4 # Copyright 2013 by Don Armstrong <don@donarmstrong.com>.
5
6 package Debbugs::Libravatar;
7
8 =head1 NAME
9
10 Debbugs::Libravatar -- Libravatar service handler (mod_perl)
11
12 =head1 SYNOPSIS
13
14 <Location /libravatar>
15    SetHandler perl-script
16    PerlResponseHandler Debbugs::Libravatar
17 </Location>
18
19 =head1 DESCRIPTION
20
21 Debbugs::Libravatar is a libravatar service handler which will serve
22 libravatar requests. It also contains utility routines which are used
23 by the libravatar.cgi script for those who do not have mod_perl.
24
25 =head1 BUGS
26
27 None known.
28
29 =cut
30
31 use warnings;
32 use strict;
33 use vars qw($VERSION $DEBUG %EXPORT_TAGS @EXPORT_OK @EXPORT);
34 use Exporter qw(import);
35
36 use Debbugs::Config qw(:config);
37 use Debbugs::Common qw(:lock);
38 use Libravatar::URL;
39 use CGI::Simple;
40 use Debbugs::CGI qw(cgi_parameters);
41 use Digest::MD5 qw(md5_hex);
42 use File::Temp qw(tempfile);
43 use File::LibMagic;
44 use Cwd qw(abs_path);
45
46 use Carp;
47
48 BEGIN{
49      ($VERSION) = q$Revision$ =~ /^Revision:\s+([^\s+])/;
50      $DEBUG = 0 unless defined $DEBUG;
51
52      @EXPORT = ();
53      %EXPORT_TAGS = (libravatar => [qw(retrieve_libravatar cache_location)]
54                     );
55      @EXPORT_OK = ();
56      Exporter::export_ok_tags(keys %EXPORT_TAGS);
57      $EXPORT_TAGS{all} = [@EXPORT_OK];
58 }
59
60
61 =over
62
63 =item retrieve_libravatar
64
65      $cache_location = retrieve_libravatar(location => $cache_location,
66                                            email => lc($param{email}),
67                                           );
68
69 Returns the cache location where a specific avatar can be loaded. If
70 there isn't a matching avatar, or there is an error, returns undef.
71
72
73 =cut
74
75 sub retrieve_libravatar{
76     my %type_mapping =
77         (jpeg => 'jpg',
78          png => 'png',
79          gif => 'png',
80          tiff => 'png',
81          tif => 'png',
82          pjpeg => 'jpg',
83          jpg => 'jpg'
84         );
85     my %param = @_;
86     my $cache_location = $param{location};
87     $cache_location =~ s/\.[^\.\/]+$//;
88     # take out a lock on the cache location so that if another request
89     # is made while we are serving this one, we don't do double work
90     my ($fh,$lockfile,$errors) =
91         simple_filelock($cache_location.'.lock',20,0.5);
92     if (not $fh) {
93         return undef;
94     } else {
95         # figure out if the cache is now valid; if it is, return the
96         # cache location
97         my ($temp_location, $is_valid) = cache_location(email => $param{email});
98         if ($is_valid) {
99             return $temp_location;
100         }
101     }
102     require LWP::UserAgent;
103
104     my $dest_type;
105     eval {
106         my $uri = libravatar_url(email => $param{email},
107                                  default => 404,
108                                  size => 80);
109         my $ua = LWP::UserAgent->new(agent => 'Debbugs libravatar service (not Mozilla)',
110                                     );
111         $ua->from($config{maintainer});
112         # if we don't get an avatar within 10 seconds, return so we
113         # don't block forever
114         $ua->timeout(10);
115         # if the avatar is bigger than 30K, we don't want it either
116         $ua->max_size(30*1024);
117         my $r = $ua->get($uri);
118         if (not $r->is_success()) {
119             die "Not successful in request";
120         }
121         my $aborted = $r->header('Client-Aborted');
122         # if we exceeded max size, I'm not sure if we'll be
123         # successfull or not, but regardless, there will be a
124         # Client-Aborted header. Stop here if that header is defined.
125         die "Client aborted header" if defined $aborted;
126         my $type = $r->header('Content-Type');
127         # if there's no content type, or it's not one we like, we won't
128         # bother going further
129         die "No content type" if not defined $type;
130         die "Wrong content type" if not $type =~ m{^image/([^/]+)$};
131         $dest_type = $type_mapping{$1};
132         die "No dest type" if not defined $dest_type;
133         # undo any content encoding
134         $r->decode() or die "Unable to decode content encoding";
135         # ok, now we need to convert it from whatever it is into a
136         # format that we actually like
137         my ($temp_fh,$temp_fn) = tempfile() or
138             die "Unable to create temporary file";
139         eval {
140             print {$temp_fh} $r->content() or
141                 die "Unable to print to temp file";
142             close ($temp_fh);
143             ### resize all images to 80x80 and strip comments out of
144             ### them. If convert has a bug, it would be possible for
145             ### this to be an attack vector, but hopefully minimizing
146             ### the size above, and requiring proper mime types will
147             ### minimize that slightly. Doing this will at least make
148             ### it harder for malicious web images to harm our users
149             system('convert','-resize','80x80',
150                    '-strip',
151                    $temp_fn,
152                    $cache_location.'.'.$dest_type) == 0 or
153                        die "convert file failed";
154             unlink($temp_fn);
155         };
156         if ($@) {
157             unlink($cache_location.'.'.$dest_type) if -e $cache_location.'.'.$dest_type;
158             unlink($temp_fn) if -e $temp_fn;
159             die "Unable to convert image";
160         }
161     };
162     if ($@) {
163         # there was some kind of error; return undef and unlock the
164         # lock
165         simple_unlockfile($fh,$lockfile);
166         return undef;
167     }
168     simple_unlockfile($fh,$lockfile);
169     return $cache_location.'.'.$dest_type;
170 }
171
172 sub blocked_libravatar {
173     my ($email,$md5sum) = @_;
174     my $blocked = 0;
175     for my $blocker (@{$config{libravatar_blacklist}||[]}) {
176         for my $element ($email,$md5sum) {
177             next unless defined $element;
178             eval {
179                 if ($element =~ /$blocker/) {
180                     $blocked=1;
181                 }
182             };
183         }
184     }
185     return $blocked;
186 }
187
188 # Returns ($path, $is_valid)
189 # - For blocked images, $path will be undef
190 # - If $is_valid is false (and $path is not undef), the image should
191 #   be re-fetched.
192 sub cache_location {
193     my %param = @_;
194     my ($md5sum, $stem);
195     if (exists $param{md5sum}) {
196         $md5sum = $param{md5sum};
197     }elsif (exists $param{email}) {
198         $md5sum = md5_hex(lc($param{email}));
199     } else {
200         croak("cache_location must be called with one of md5sum or email");
201     }
202     return (undef, 0) if blocked_libravatar($param{email},$md5sum);
203     $stem = $config{libravatar_cache_dir}.'/'.$md5sum;
204     for my $ext ('.png', '.jpg', '') {
205         my $path = $stem.$ext;
206         if (-e $path) {
207             my $is_valid = (time - (stat(_))[9] < 60*60) ? 1 : 0;
208             return ($path, $is_valid);
209         }
210     }
211     return ($stem, 0);
212 }
213
214 ## the following is mod_perl specific
215
216 BEGIN{
217     if (exists $ENV{MOD_PERL_API_VERSION}) {
218         if ($ENV{MOD_PERL_API_VERSION} == 2) {
219             require Apache2::RequestIO;
220             require Apache2::RequestRec;
221             require Apache2::RequestUtil;
222             require Apache2::Const;
223             require APR::Finfo;
224             require APR::Const;
225             APR::Const->import(-compile => qw(FINFO_NORM));
226             Apache2::Const->import(-compile => qw(OK DECLINED FORBIDDEN NOT_FOUND HTTP_NOT_MODIFIED));
227         } else {
228             die "Unsupported mod perl api; mod_perl 2.0.0 or later is required";
229         }
230     }
231 }
232
233 sub handler {
234     die "Calling handler only makes sense if this is running under mod_perl" unless exists $ENV{MOD_PERL_API_VERSION};
235     my $r = shift or Apache2::RequestUtil->request;
236
237     # we only want GET or HEAD requests
238     unless ($r->method eq 'HEAD' or $r->method eq 'GET') {
239         return Apache2::Const::DECLINED();
240     }
241     $r->headers_out->{"X-Powered-By"} = "Debbugs libravatar";
242
243     my $uri = $r->uri();
244     # subtract out location
245     my $location = $r->location();
246     my ($email) = $uri =~ m/\Q$location\E\/?(.*)$/;
247     if (not length $email) {
248         return Apache2::Const::NOT_FOUND();
249     }
250     my $q = CGI::Simple->new();
251     my %param = cgi_parameters(query => $q,
252                                single => [qw(avatar)],
253                                default => {avatar => 'yes',
254                                           },
255                               );
256     if ($param{avatar} ne 'yes' or not defined $email or not length $email) {
257         serve_cache_mod_perl('',$r);
258         return Apache2::Const::DECLINED();
259     }
260     # figure out what the md5sum of the e-mail is.
261     my ($cache_location, $is_valid) = cache_location(email => $email);
262     # if we've got it, and it's less than one hour old, return it.
263     if ($is_valid) {
264         serve_cache_mod_perl($cache_location,$r);
265         return Apache2::Const::DECLINED();
266     }
267     $cache_location = retrieve_libravatar(location => $cache_location,
268                                           email => $email,
269                                          );
270     if (not defined $cache_location) {
271         # failure, serve the default image
272         serve_cache_mod_perl('',$r);
273         return Apache2::Const::DECLINED();
274     } else {
275         serve_cache_mod_perl($cache_location,$r);
276         return Apache2::Const::DECLINED();
277     }
278 }
279
280
281
282 our $magic;
283
284 sub serve_cache_mod_perl {
285     my ($cache_location,$r) = @_;
286     if (not defined $cache_location or not length $cache_location) {
287         # serve the default image
288         $cache_location = $config{libravatar_default_image};
289     }
290     $magic = File::LibMagic->new() if not defined $magic;
291
292     return Apache2::Const::DECLINED() if not defined $magic;
293
294     $r->content_type($magic->checktype_filename(abs_path($cache_location)));
295
296     $r->filename($cache_location);
297     $r->path_info('');
298     $r->finfo(APR::Finfo::stat($cache_location, APR::Const::FINFO_NORM(), $r->pool));
299 }
300
301 =back
302
303 =cut
304
305 1;
306
307
308 __END__