root/trunk/lib/Perlbal/ClientHTTP.pm

Revision 810, 15.0 kB (checked in by hachi, 12 months ago)

Allow zero byte content-length PUT requests

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1######################################################################
2# HTTP Connection from a reverse proxy client.  GET/HEAD only.
3#  most functionality is implemented in the base class.
4#
5# Copyright 2004, Danga Interactive, Inc.
6# Copyright 2005-2007, Six Apart, Ltd.
7#
8
9package Perlbal::ClientHTTP;
10use strict;
11use warnings;
12no  warnings qw(deprecated);
13
14use base "Perlbal::ClientHTTPBase";
15use Perlbal::Util;
16
17use fields ('put_in_progress', # 1 when we're currently waiting for an async job to return
18            'put_fh',          # file handle to use for writing data
19            'put_fh_filename', # filename of put_fh
20            'put_pos',         # file offset to write next data at
21
22            'content_length',  # length of document being transferred
23            'content_length_remain', # bytes remaining to be read
24            'chunked_upload_state', # bool/obj:  if processing a chunked upload, Perlbal::ChunkedUploadState object, else undef
25            );
26
27use HTTP::Date ();
28use File::Path;
29
30use Errno qw( EPIPE );
31use POSIX qw( O_CREAT O_TRUNC O_WRONLY O_RDONLY ENOENT );
32
33# class list of directories we know exist
34our (%VerifiedDirs);
35
36sub new {
37    my $class = shift;
38
39    my $self = fields::new($class);
40    $self->SUPER::new(@_);
41    $self->init;
42    return $self;
43}
44
45# upcasting a generic ClientHTTPBase (from a service selector) to a
46# "full-fledged" ClientHTTP.
47sub new_from_base {
48    my $class = shift;
49    my Perlbal::ClientHTTPBase $cb = shift;    # base object
50    Perlbal::Util::rebless($cb, $class);
51    $cb->init;
52
53    $cb->watch_read(1);   # enable our reads, so we can get PUT/POST data
54    $cb->handle_request;  # this will disable reads, if GET/HEAD/etc
55    return $cb;
56}
57
58sub init {
59    my Perlbal::ClientHTTP $self = shift;
60    $self->{put_in_progress} = 0;
61    $self->{put_fh} = undef;
62    $self->{put_pos} = 0;
63    $self->{chunked_upload_state} = undef;
64}
65
66sub close {
67    my Perlbal::ClientHTTP $self = shift;
68
69    # don't close twice
70    return if $self->{closed};
71
72    $self->{put_fh} = undef;
73    $self->SUPER::close(@_);
74}
75
76sub event_read {
77    my Perlbal::ClientHTTP $self = shift;
78    $self->{alive_time} = $Perlbal::tick_time;
79
80    # see if we have headers?
81    if ($self->{req_headers}) {
82        if ($self->{req_headers}->request_method eq 'PUT') {
83            $self->event_read_put;
84        } else {
85            # since we have headers and we're not doing any special
86            # handling above, let's just disable read notification, because
87            # we won't do anything with the data
88            $self->watch_read(0);
89        }
90        return;
91    }
92
93    # try and get the headers, if they're all here
94    my $hd = $self->read_request_headers
95        or return;
96
97    $self->handle_request;
98}
99
100# one-time routing of new request to the right handlers
101sub handle_request {
102    my Perlbal::ClientHTTP $self = shift;
103    my $hd = $self->{req_headers};
104
105    $self->check_req_headers;
106
107    # fully formed request received
108    $self->{requests}++;
109
110    # notify that we're about to serve
111    return if $self->{service}->run_hook('start_web_request',  $self);
112    return if $self->{service}->run_hook('start_http_request', $self);
113
114    # GET/HEAD requests (local, from disk)
115    if ($hd->request_method eq 'GET' || $hd->request_method eq 'HEAD') {
116        # and once we have it, start serving
117        $self->watch_read(0);
118        return $self->_serve_request($hd);
119    }
120
121    # PUT requests
122    return $self->handle_put    if $hd->request_method eq 'PUT';
123
124    # DELETE requests
125    return $self->handle_delete if $hd->request_method eq 'DELETE';
126
127    # else, bad request
128    return $self->send_response(400);
129}
130
131sub handle_put {
132    my Perlbal::ClientHTTP $self = shift;
133    my $hd = $self->{req_headers};
134
135    return $self->send_response(403) unless $self->{service}->{enable_put};
136
137    return if $self->handle_put_chunked;
138
139    # they want to put something, so let's setup and wait for more reads
140    my $clen =
141        $self->{content_length} =
142        $self->{content_length_remain} =
143        $hd->header('Content-length') + 0;
144
145    # return a 400 (bad request) if we got no content length or if it's
146    # bigger than any specified max put size
147    return $self->send_response(400, "Content-length of $clen is invalid.")
148        if ! defined($clen) ||
149        $clen < 0 ||
150        ($self->{service}->{max_put_size} &&
151         $clen > $self->{service}->{max_put_size});
152
153    # if we are supposed to read data and have some data already from a header over-read, note it
154    if ($clen && defined $self->{read_ahead} && $self->{read_ahead} > 0) {
155        $self->{content_length_remain} -= $self->{read_ahead};
156    }
157
158    return if $self->{service}->run_hook('handle_put', $self);
159
160    # error in filename?  (any .. is an error)
161    my $uri = $self->{req_headers}->request_uri;
162    return $self->send_response(400, 'Invalid filename')
163        if $uri =~ /\.\./;
164
165    # now we want to get the URI
166    return $self->send_response(400, 'Invalid filename')
167        unless $uri =~ m!^
168            ((?:/[\w\-\.]+)*)      # $1: zero+ path components of /FOO where foo is
169                                     #   one+ conservative characters
170                  /                  # path separator
171            ([\w\-\.]+)            # $2: and the filename, one+ conservative characters
172            $!x;
173
174    # sanitize uri into path and file into a disk path and filename
175    my ($path, $filename) = ($1 || '', $2);
176
177    # the final action we'll be taking, eventually, is to start an async
178    # file open of the requested disk path.  but we might need to verify
179    # the min_put_directory first.
180    my $start_open = sub {
181        my $disk_path = $self->{service}->{docroot} . '/' . $path;
182        $self->start_put_open($disk_path, $filename);
183    };
184
185    # verify minput if necessary
186    if ($self->{service}->{min_put_directory}) {
187        my @elems = grep { defined $_ && length $_ } split '/', $path;
188        return $self->send_response(400, 'Does not meet minimum directory requirement')
189            unless scalar(@elems) >= $self->{service}->{min_put_directory};
190        my $req_path   = '/' . join('/', splice(@elems, 0, $self->{service}->{min_put_directory}));
191        my $extra_path = '/' . join('/', @elems);
192        $self->validate_min_put_directory($req_path, $extra_path, $filename, $start_open);
193    } else {
194        $start_open->();
195    }
196
197    return;
198}
199
200sub handle_put_chunked {
201    my Perlbal::ClientHTTP $self = shift;
202    my $req_hd = $self->{req_headers};
203    my $te = $req_hd->header("Transfer-Encoding");
204    return unless $te && $te eq "chunked";
205
206    my $eh = $req_hd->header("Expect");
207    if ($eh && $eh =~ /\b100-continue\b/) {
208        $self->write(\ "HTTP/1.1 100 Continue\r\n\r\n");
209    }
210
211    my $max_size = $self->{service}{max_chunked_request_size};
212
213    # error in filename?  (any .. is an error)
214    my $uri = $self->{req_headers}->request_uri;
215    return $self->send_response(400, 'Invalid filename')
216        if $uri =~ /\.\./;
217
218    # now we want to get the URI
219    return $self->send_response(400, 'Invalid filename')
220        unless $uri =~ m!^
221            ((?:/[\w\-\.]+)*)      # $1: zero+ path components of /FOO where foo is
222                                     #   one+ conservative characters
223                  /                  # path separator
224            ([\w\-\.]+)            # $2: and the filename, one+ conservative characters
225            $!x;
226
227    # sanitize uri into path and file into a disk path and filename
228    my ($path, $filename) = ($1 || '', $2);
229
230    my $disk_path = $self->{service}->{docroot} . '/' . $path;
231
232    $self->{chunked_upload_state} = Perlbal::ChunkedUploadState->new(%{{
233        on_new_chunk => sub {
234            my $cref = shift;
235            my $len = length($$cref);
236            push @{$self->{read_buf}}, $cref;
237
238            $self->{read_ahead}     += $len;
239            $self->{content_length} += $len;
240
241            # if too large, disconnect them...
242            if ($max_size && $self->{content_length} > $max_size) {
243                # TODO: delete file at this point?  we're disconnecting them
244                # to prevent them from writing more, but do we care to keep
245                # what they already wrote?
246                $self->close;
247                return;
248            }
249
250            $self->put_writeout if $self->{read_ahead} >= 8192; # arbitrary
251        },
252        on_disconnect => sub {
253            warn "Disconnect during chunked PUT.\n";
254
255            # TODO: do we unlink the file here, since it wasn't a proper close
256            # ending in a zero-length chunk?  perhaps a config option? for
257            # now we'll just leave it on disk with what we've got so far:
258            $self->close('remote_closure_during_chunked_put');
259        },
260        on_zero_chunk => sub {
261            $self->{chunked_upload_state} = undef;
262            $self->watch_read(0);
263
264            # kick off any necessary aio writes:
265            $self->put_writeout;
266            # this will do nothing, if a put is already in progress:
267            $self->put_close;
268        },
269    }});
270
271    $self->start_put_open($disk_path, $filename);
272
273    return 1;
274}
275
276# called when we're requested to do a delete
277sub handle_delete {
278    my Perlbal::ClientHTTP $self = shift;
279
280    return $self->send_response(403) unless $self->{service}->{enable_delete};
281
282    $self->watch_read(0);
283
284    # error in filename?  (any .. is an error)
285    my $uri = $self->{req_headers}->request_uri;
286    return $self->send_response(400, 'Invalid filename')
287        if $uri =~ /\.\./;
288
289    # now we want to get the URI
290    if ($uri =~ m!^(?:/[\w\-\.]+)+$!) {
291        # now attempt the unlink
292        Perlbal::AIO::aio_unlink($self->{service}->{docroot} . '/' . $uri, sub {
293            my $err = shift;
294            if ($err == 0 && !$!) {
295                # delete was successful
296                return $self->send_response(204);
297            } elsif ($! == ENOENT) {
298                # no such file
299                return $self->send_response(404);
300            } else {
301                # failure...
302                return $self->send_response(400, "$!");
303            }
304        });
305    } else {
306        # bad URI, don't accept the delete
307        return $self->send_response(400, 'Invalid filename');
308    }
309}
310
311sub event_read_put {
312    my Perlbal::ClientHTTP $self = shift;
313
314    if (my $cus = $self->{chunked_upload_state}) {
315        $cus->on_readable($self);
316        return;
317    }
318
319    # read in data and shove it on the read buffer
320    my $dataref = $self->read($self->{content_length_remain});
321
322    # unless they disconnected prematurely
323    unless (defined $dataref) {
324        $self->close('remote_closure');
325        return;
326    }
327
328    # got some data
329    push @{$self->{read_buf}}, $dataref;
330    my $clen = length($$dataref);
331    $self->{read_size}  += $clen;
332    $self->{read_ahead} += $clen;
333    $self->{content_length_remain} -= $clen;
334
335    if ($self->{content_length_remain}) {
336        $self->put_writeout if $self->{read_ahead} >= 8192; # arbitrary
337    } else {
338        # now, if we've filled the content of this put, we're done
339        $self->watch_read(0);
340        $self->put_writeout;
341    }
342}
343
344# verify that a minimum put directory exists.  if/when it's verified,
345# perhaps cached, the provided callback will be run.
346sub validate_min_put_directory {
347    my Perlbal::ClientHTTP $self = shift;
348    my ($req_path, $extra_path, $filename, $callback) = @_;
349
350    my $disk_dir = $self->{service}->{docroot} . '/' . $req_path;
351    return $callback->() if $VerifiedDirs{$disk_dir};
352
353    $self->{put_in_progress} = 1;
354
355    Perlbal::AIO::aio_open($disk_dir, O_RDONLY, 0755, sub {
356        my $fh = shift;
357        $self->{put_in_progress} = 0;
358
359        # if error return failure
360        return $self->send_response(404, "Base directory does not exist") unless $fh;
361        CORE::close($fh);
362
363        # mindir existed, mark it as so and start the open for the rest of the path
364        $VerifiedDirs{$disk_dir} = 1;
365        $callback->();
366    });
367}
368
369# attempt to open a file being PUT for writing to disk
370sub start_put_open {
371    my Perlbal::ClientHTTP $self = shift;
372    my ($path, $file) = @_;
373
374    $self->{put_in_progress} = 1;
375
376    Perlbal::AIO::aio_open("$path/$file", O_CREAT | O_TRUNC | O_WRONLY, 0644, sub {
377        # get the fd
378        my $fh = shift;
379
380        # verify file was opened
381        $self->{put_in_progress} = 0;
382
383        if (! $fh) {
384            if ($! == ENOENT) {
385                # directory doesn't exist, so let's manually create it
386                eval { File::Path::mkpath($path, 0, 0755); };
387                return $self->system_error("Unable to create directory", "path = $path, file = $file") if $@;
388
389                # should be created, call self recursively to try
390                return $self->start_put_open($path, $file);
391            } else {
392                return $self->system_error("Internal error", "error = $!, path = $path, file = $file");
393            }
394        }
395
396        $self->{put_fh}          = $fh;
397        $self->{put_pos}         = 0;
398        $self->{put_fh_filename} = "$path/$file";
399
400        # We just opened the file, haven't read_ahead any bytes, are expecting 0 bytes for read and we're
401        # not in chunked mode, so close the file immediately, we're done.
402        unless ($self->{read_ahead} || $self->{content_length_remain} || $self->{chunked_upload_state}) {
403            # FIXME this should be done through AIO
404            $self->put_close;
405            return;
406        }
407
408        $self->put_writeout;
409    });
410}
411
412# called when we've got some put data to write out
413sub put_writeout {
414    my Perlbal::ClientHTTP $self = shift;
415    Carp::confess("wrong class for $self") unless ref $self eq "Perlbal::ClientHTTP";
416
417    return if $self->{service}->run_hook('put_writeout', $self);
418    return if $self->{put_in_progress};
419    return unless $self->{put_fh};
420    return unless $self->{read_ahead};
421
422    my $data = join("", map { $$_ } @{$self->{read_buf}});
423    my $count = length $data;
424
425    # reset our input buffer
426    $self->{read_buf}   = [];
427    $self->{read_ahead} = 0;
428
429    # okay, file is open, write some data
430    $self->{put_in_progress} = 1;
431
432    Perlbal::AIO::set_file_for_channel($self->{put_fh_filename});
433    Perlbal::AIO::aio_write($self->{put_fh}, $self->{put_pos}, $count, $data, sub {
434        return if $self->{closed};
435
436        # see how many bytes written
437        my $bytes = shift() + 0;
438
439        $self->{put_pos} += $bytes;
440        $self->{put_in_progress} = 0;
441
442        # now recursively call ourselves?
443        if ($self->{read_ahead}) {
444            $self->put_writeout;
445            return;
446        }
447
448        return if $self->{content_length_remain} || $self->{chunked_upload_state};
449
450        # we're done putting this file, so close it.
451        # FIXME this should be done through AIO
452        $self->put_close;
453    });
454}
455
456sub put_close {
457    my Perlbal::ClientHTTP $self = shift;
458    return if $self->{put_in_progress};
459    return unless $self->{put_fh};
460
461    if (CORE::close($self->{put_fh})) {
462        $self->{put_fh} = undef;
463        return $self->send_response(200);
464    } else {
465        return $self->system_error("Error saving file", "error in close: $!");
466    }
467}
468
4691;
470
471# Local Variables:
472# mode: perl
473# c-basic-indent: 4
474# indent-tabs-mode: nil
475# End:
Note: See TracBrowser for help on using the browser.