| 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 | |
|---|
| 9 | package Perlbal::ClientHTTP; |
|---|
| 10 | use strict; |
|---|
| 11 | use warnings; |
|---|
| 12 | no warnings qw(deprecated); |
|---|
| 13 | |
|---|
| 14 | use base "Perlbal::ClientHTTPBase"; |
|---|
| 15 | use Perlbal::Util; |
|---|
| 16 | |
|---|
| 17 | use 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 | |
|---|
| 27 | use HTTP::Date (); |
|---|
| 28 | use File::Path; |
|---|
| 29 | |
|---|
| 30 | use Errno qw( EPIPE ); |
|---|
| 31 | use POSIX qw( O_CREAT O_TRUNC O_WRONLY O_RDONLY ENOENT ); |
|---|
| 32 | |
|---|
| 33 | # class list of directories we know exist |
|---|
| 34 | our (%VerifiedDirs); |
|---|
| 35 | |
|---|
| 36 | sub 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. |
|---|
| 47 | sub 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 | |
|---|
| 58 | sub 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 | |
|---|
| 66 | sub 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 | |
|---|
| 76 | sub 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 |
|---|
| 101 | sub 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 | |
|---|
| 131 | sub 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 | |
|---|
| 200 | sub 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 |
|---|
| 277 | sub 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 | |
|---|
| 311 | sub 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. |
|---|
| 346 | sub 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 |
|---|
| 370 | sub 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 |
|---|
| 413 | sub 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 | |
|---|
| 456 | sub 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 | |
|---|
| 469 | 1; |
|---|
| 470 | |
|---|
| 471 | # Local Variables: |
|---|
| 472 | # mode: perl |
|---|
| 473 | # c-basic-indent: 4 |
|---|
| 474 | # indent-tabs-mode: nil |
|---|
| 475 | # End: |
|---|