File Coverage

blib/lib/HTTP/Upload/FlowJs.pm
Criterion Covered Total %
statement 250 292 85.6
branch 61 92 66.3
condition 69 101 68.3
subroutine 27 30 90.0
pod 20 20 100.0
total 427 535 79.8


line stmt bran cond sub pod time code
1             package HTTP::Upload::FlowJs;
2 4     4   543393 use strict;
  4         6  
  4         172  
3 4     4   22 use Carp qw(croak);
  4         6  
  4         285  
4 4     4   2016 use Filter::signatures;
  4         97180  
  4         29  
5 4     4   203 no warnings 'experimental::signatures';
  4         11  
  4         200  
6 4     4   43 use feature 'signatures';
  4         7  
  4         142  
7 4     4   2224 use Text::CleanFragment 'clean_fragment';
  4         17793  
  4         303  
8 4     4   660 use Data::Dumper;
  4         10952  
  4         255  
9 4     4   2173 use MIME::Detect;
  4         521318  
  4         368  
10              
11             our $VERSION = '0.02';
12              
13 4     4   3599 use JSON qw(encode_json decode_json);
  4         56502  
  4         37  
14              
15             =head1 NAME
16              
17             HTTP::Upload::FlowJs - handle resumable multi-part HTTP uploads with flowjs
18              
19             =head1 SYNOPSIS
20              
21             This synopsis assumes a L/L-like environment. There are
22             plugins for L and L planned. See
23             L for longer examples.
24              
25             The C workflow assumes that your application handles two kinds of
26             requests, POST requests for storing the payload data and GET requests for
27             retrieving information about uploaded parts. You will have to make various calls
28             to the HTTP::Upload::FlowJs object to validate the incoming request at every
29             stage.
30              
31             use HTTP::Upload::FlowJs;
32              
33             my $uploads = '/tmp/flowjs_uploads/';
34             my $flowjs = HTTP::Upload::FlowJs->new(
35             incomingDirectory => $uploads,
36             allowedContentType => sub { $_[0] =~ m!^image/! },
37             );
38              
39             my @parameter_names = $flowjs->parameter_names();
40              
41             # In your POST handler for /upload:
42             sub POST_upload {
43             my $params = params();
44              
45             my %info;
46             @info{ @parameter_names } = @{$params}{@parameter_names};
47             $info{ localChunkSize } = -s $params{ file };
48             # or however you get the size of the uploaded chunk
49              
50             # you might want to set this so users don't clobber each others upload
51             my $session_id = '';
52             my @invalid = $flowjs->validateRequest( 'POST', \%info, $session_id );
53             if( @invalid ) {
54             warn 'Invalid flow.js upload request:';
55             warn $_ for @invalid;
56             return [500,[],["Invalid request"]];
57             return;
58             };
59              
60             if( $flowjs->disallowedContentType( \%info, $session_id )) {
61             # We can determine the content type, and it's not an image
62             return [415,[],["File type disallowed"]];
63             };
64              
65             my $chunkname = $flowjs->chunkName( \%info, undef );
66              
67             # Save or copy the uploaded file
68             upload('file')->copy_to($chunkname);
69              
70             # Now check if we have received all chunks of the file
71             if( $flowjs->uploadComplete( \%info, undef )) {
72             # Combine all chunks to final name
73              
74             my $digest = Digest::SHA256->new();
75              
76             my( $content_type, $ext ) = $flowjs->sniffContentType();
77             my $final_name = "file1.$ext";
78             open( my $fh, '>', $final_name )
79             or die $!;
80             binmode $fh;
81              
82             my( $ok, @unlink_chunks )
83             = $flowjs->combineChunks( \%info, undef, $fh, $digest );
84             unlink @unlink_chunks;
85              
86             # Notify backend that a file arrived
87             print sprintf "File '%s' upload complete\n", $final_name;
88             };
89              
90             # Signal OK
91             return [200,[],[]]
92             };
93              
94             # This checks whether a file has been received completely or
95             # needs to be uploaded again
96             sub GET_upload {
97             my $params = params();
98             my %info;
99             @info{ @parameter_names} = @{$params}{@parameter_names};
100              
101             my @invalid = $flowjs->validateRequest( 'GET', \%info, session->{connid} );
102             if( @invalid ) {
103             warn 'Invalid flow.js upload request:';
104             warn $_ for @invalid;
105             return [500, [], [] ];
106              
107             } elsif( $flowjs->disallowedContentType( \%info, $session_id)) {
108             # We can determine the content type, and it's not an image
109             return [415,[],["File type disallowed"]];
110              
111             } else {
112             my( $status, @messages )
113             = $flowjs->chunkOK( $uploads, \%info, $session_id );
114             if( $status != 500 ) {
115             # 200 or 416
116             return [$status, [], [] ];
117             } else {
118             warn $_ for @messages;
119             return [$status, [], [] ];
120             };
121             };
122             };
123              
124             =head1 OVERVIEW
125              
126             L is a client-side Javascript upload
127             library that uploads
128             a file in multiple parts. It requires two API points on the server side,
129             one C API point to check whether a part already has been uploaded
130             completely and one C API point to send the data of each partial
131             upload to. This Perl module implements the backend functionality for
132             both endpoints. It does not implement the handling of the HTTP requests
133             themselves, but you likely already use a framework like L
134             or L for that.
135              
136             =head1 METHODS
137              
138             =head2 C<< HTTP::Upload::FlowJs->new >>
139              
140             my $flowjs = HTTP::Upload::FlowJs->new(
141             maxChunkCount => 1000,
142             maxFileSize => 10_000_000,
143             maxChunkSize => 1024*1024,
144             simultaneousUploads => 3,
145             allowedContentType => sub {
146             my($type) = @_;
147             $type =~ m!^image/!; # we only allow for cat images
148             },
149             );
150              
151             =over 4
152              
153             B - path for the temporary upload parts
154              
155             Required
156              
157             B - hard maximum chunks allowed for a single upload
158              
159             Default 1000
160              
161             B - hard maximum total file size for a single upload
162              
163             Default 10_000_000
164              
165             B - hard maximum chunk size for a single chunk
166              
167             Default 1048576
168              
169             B - hard minimum chunk size for a single chunk
170              
171             Default 1024
172              
173             The minimum chunk size is required since the file type detection
174             works on the first chunk. If the first chunk is too small, its file type
175             cannot be checked.
176              
177             B - force all chunks to be less or equal than C
178              
179             Default: true
180              
181             Otherwise, the last chunk will be greater than or equal to C
182             (the last uploaded chunk will be at least this size and up to two the size).
183              
184             Note: when C is C it only make C value in
185             L equal to C.
186              
187             B - simultaneously allowed uploads per file
188              
189             Default 3
190              
191             This is just an indication to the Javascript C client
192             if you pass it the configuration from this object. This is not enforced
193             in any way yet.
194              
195             B - subroutine to check the MIME type
196              
197             The default is to allow any kind of file
198              
199             If you need more advanced checking, do so after you've determined a file
200             upload as complete with C<< $flowjs->uploadComplete >>.
201              
202             B - The name of the multipart POST parameter to use for the
203             file chunk
204              
205             Default C
206              
207             =back
208              
209             More interesting limits would be hard maxima for the number of pending
210             uploads or the number of outstanding chunks per user/session. Checking
211             these would entail a call to C for each check and thus would be
212             fairly disk-intensive on some systems.
213              
214             =cut
215              
216 12     12 1 721354 sub new( $class, %options ) {
  12         39  
  12         58  
  12         24  
217             croak "Need a directory name for the temporary upload parts"
218 12 50       71 unless $options{ incomingDirectory };
219              
220 12   50     145 $options{ maxChunkCount } ||= 1000;
221 12   50     76 $options{ maxFileSize } ||= 10_000_000;
222 12   100     94 $options{ maxChunkSize } ||= 1024*1024;
223 12   100     88 $options{ minChunkSize } //= 1024;
224 12   100     89 $options{ forceChunkSize } //= 1;
225 12   50     103 $options{ simultaneousUploads } ||= 3;
226 12   33     637 $options{ mime } ||= MIME::Detect->new();
227 12   50     6622684 $options{ fileParameterName } ||= 'file';
228 12   66 0   378 $options{ allowedContentType } ||= sub { 1 };
  0         0  
229              
230 12         715 bless \%options => $class;
231             };
232              
233             =head2 C<< $flowjs->incomingDirectory >>
234              
235             Return the incoming directory name.
236              
237             =cut
238              
239 42     42 1 77 sub incomingDirectory( $self ) {
  42         83  
  42         60  
240 42         158 $self->{incomingDirectory};
241             };
242              
243             =head2 C<< $flowjs->mime >>
244              
245             Return the L instance.
246              
247             =cut
248              
249 11     11 1 22 sub mime($self) {
  11         18  
  11         19  
250             $self->{mime}
251 11         102 };
252              
253             =head2 C<< $flowjs->jsConfig >>
254              
255             =head2 C<< $flowjs->jsConfigStr >>
256              
257             # Perl HASH
258             my $config = $flowjs->jsConfig(
259             target => '/upload',
260             );
261              
262             # JSON string
263             my $config = $flowjs->jsConfigStr(
264             target => '/upload',
265             );
266              
267             Create a JSON string that encapsulates the configuration of the Perl
268             object for inclusion with the JS side of the world.
269              
270             =cut
271              
272 3     3 1 12853 sub jsConfig( $self, %override ) {
  3         7  
  3         11  
  3         16  
273             # The last uploaded chunk will be at least this size and up to two the size
274             # when forceChunkSize is false
275 3         19 my $chunkSize = $self->{maxChunkSize};
276 3 100       18 $chunkSize = $chunkSize/2 unless $self->{forceChunkSize}; # / placate Filter::Simple
277              
278             +{
279             (
280 3         10 map { $_ => $self->{$_} } (qw(
  6         226  
281             simultaneousUploads
282             forceChunkSize
283             ))
284             ),
285             chunkSize => $chunkSize,
286             testChunks => 1,
287             withCredentials => 1,
288             uploadMethod => 'POST',
289             %override,
290             };
291             }
292              
293 1     1 1 5 sub jsConfigStr( $self, %override ) {
  1         2  
  1         4  
  1         2  
294 1         5 encode_json($self->jsConfig(%override))
295             }
296              
297             =head2 C<< $flowjs->parameter_names >>
298              
299             my $params = params(); # request params
300             my @parameter_names = $flowjs->parameter_names; # params needed by Flowjs
301              
302             my %info;
303             @info{ @parameter_names } = @{$params}{@parameter_names};
304              
305             $info{ file } = $params{ file };
306             $info{ localChunkSize } = -s $params{ file };
307              
308             my @invalid = $flowjs->validateRequest( 'POST', \%info );
309              
310             Returns needed params for validating request.
311              
312             =cut
313              
314 54     54 1 83 sub parameter_names( $self, $required_params ) {
  54         72  
  54         96  
  54         75  
315             my $params = $self->{parameter_names} ||= {
316 54   100     433 flowChunkNumber => 1,
317             flowTotalChunks => 1,
318             flowChunkSize => 1,
319             flowCurrentChunkSize => 1,
320             flowTotalSize => 1,
321             flowIdentifier => 1,
322             flowFilename => 1,
323             flowRelativePath => 0,
324             };
325              
326 54 50       175 if ( $required_params ) {
327 54         92 return grep { $params->{$_} } keys( %{$params} );
  432         1528  
  54         283  
328             }
329              
330 0         0 return keys( %{$params} );
  0         0  
331             }
332              
333             =head2 C<< $flowjs->validateRequest >>
334              
335             my $session_id = '';
336             my @invalid = $flowjs->validateRequest( 'POST', \%info, $session_id );
337             if( @invalid ) {
338             warning 'Invalid flow.js upload request:';
339             warning $_ for @invalid;
340             status 500;
341             return;
342             };
343              
344             Does formal validation of the request HTTP parameters. It does not
345             check previously stored information.
346              
347             B when C there are addition required params C
348             and C<$self->{fileParameterName}> (default 'file').
349              
350             =cut
351              
352 54     54 1 57855 sub validateRequest( $self, $method, $info, $sessionId=undef ) {
  54         134  
  54         101  
  54         82  
  54         83  
  54         92  
353             # Validate the input somewhat
354 54         128 local $Data::Dumper::Useqq = 1;
355              
356 54         87 my @invalid;
357              
358 54         159 my @required = $self->parameter_names('required');
359 54 100       201 if( $method eq 'POST') {
360 16         60 push @required, $self->{fileParameterName}, 'localChunkSize'
361             ;
362             };
363              
364 54         121 for my $param (@required) {
365 410 50 33     1343 if( ! exists $info->{ $param } or !defined $info->{$param}) {
366 0         0 push @invalid, sprintf 'Parameter [%s] is required but is missing',
367             $param,
368             ;
369             };
370             };
371 54 50       153 if( @invalid ) {
372 0         0 return @invalid;
373             };
374              
375             # Numbers should be numbers
376 54         102 for my $param (qw(flowChunkNumber flowTotalChunks flowChunkSize flowTotalSize flowCurrentChunkSize)) {
377 270 50 33     1463 if( exists $info->{ $param } and $info->{ $param } !~ /^[0-9]+$/) {
378             push @invalid, sprintf 'Parameter [%s] should be numeric, but is [%s]; set to 0',
379             $param,
380 0         0 Dumper $info->{$param}
381             ;
382 0         0 $info->{ $param } = 0;
383             };
384             };
385              
386             # Check maximum chunk count
387 54         98 for my $param (qw(flowChunkNumber flowTotalChunks )) {
388 108 100 66     449 if( exists $info->{ $param } and not $info->{ $param } <= $self->{maxChunkCount}) {
389             push @invalid, sprintf 'Parameter [%s] should be less than %d, but is [%s]',
390             $param,
391             $self->{maxChunkCount},
392 1         9 $info->{$param},
393             ;
394             };
395             };
396              
397             # The chunk number needs to be less than or equal to the total chunks
398 54 50 50     276 if( ($info->{ flowChunkNumber } || 0) > ($info->{ flowTotalChunks } || 0)) {
      50        
399             push @invalid, sprintf 'Flow chunk number [%s] is greater than the number of total chunks [%s]',
400             $info->{ flowChunkNumber },
401             $info->{ flowTotalChunks },
402 0         0 ;
403             };
404              
405             # Filenames should contain no path fragments
406             # This will interact badly with directory uploads, but oh well
407 54         92 for my $param (qw(flowFilename)) {
408             # Sanitize the filename
409 54 50 33     327 if( exists $info->{ $param } and $info->{ $param } =~ m![/\\]! ) {
410             push @invalid, sprintf 'Parameter [%s] contains invalid path segments',
411             $param,
412 0         0 Dumper $info->{$param}
413             ;
414             };
415             };
416              
417             # Filenames and paths should not contain upward directory references
418 54         107 for my $param (qw(flowFilename flowRelativePath)) {
419             # Sanitize the filename
420 108 50 66     393 if( exists $info->{ $param } and $info->{ $param } =~ m![/\\]\.\.[/\\]! ) {
421             push @invalid, sprintf 'Parameter [%s] contains invalid upward path segments [%s]',
422             $param,
423 0         0 Dumper $info->{$param}
424             ;
425             };
426             };
427              
428             # The filename shouldn't contain control characters
429 54         109 for my $param (qw(flowFilename flowRelativePath)) {
430 108 50 66     411 if( exists $info->{ $param } and $info->{ $param } =~ m![\x00-\x1f]! ) {
431             push @invalid, sprintf 'Parameter [%s] contains control characters [%s]',
432             $param,
433 0         0 Dumper $info->{$param}
434             ;
435             };
436             };
437              
438              
439              
440 54         91 my $min_max_error = 0;
441 54         90 for my $param (qw(flowChunkSize flowCurrentChunkSize)) {
442 108 50 33     431 if( exists $info->{ $param } and $info->{ $param } > $self->{ maxChunkSize } ) {
443 0         0 $min_max_error = 1;
444             push @invalid, sprintf 'Uploaded chunk [%d] of file [%s] is too large [%d], allowed is [%d]',
445             $info->{flowChunkNumber},
446             $info->{flowFilename},
447             $info->{$param},
448             $self->{maxChunkSize},
449 0         0 ;
450              
451             }
452             }
453              
454 54         92 for my $param (qw(flowChunkSize flowCurrentChunkSize)) {
455 108 100 66     441 if( exists $info->{ $param } and $info->{ $param } < $self->{ minChunkSize }
      66        
      100        
456             and ( $info->{flowChunkNumber} < $info->{flowTotalChunks} # only last chunk could be smaller
457             or $info->{flowTotalChunks} <= 1 # when total chunks > 1
458             )
459             ) {
460 2         4 $min_max_error = 1;
461             push @invalid, sprintf 'Uploaded chunk [%d] of file [%s] is too small [%d], allowed is [%d]',
462             $info->{flowChunkNumber},
463             $info->{flowFilename},
464             $info->{$param},
465             $self->{minChunkSize},
466 2         19 ;
467              
468             }
469             }
470              
471 54 50 100     679 if( ! $min_max_error and ($info->{ flowTotalSize } || 0) > $self->{ maxFileSize } ) {
    100 66        
    50 100        
    100 100        
    50 66        
      100        
      100        
      66        
472             # Uploaded file would be too large
473             push @invalid, sprintf 'Uploaded file [%s] would be too large ([%d]) allowed is [%d]',
474             $info->{flowFilename},
475             $info->{flowTotalSize},
476             $self->{maxFileSize},
477 0         0 ;
478              
479             } elsif( ! $min_max_error and $method eq 'POST' and $info->{ localChunkSize } > $info->{flowChunkSize} ) {
480             # Uploaded chunk is larger than the maximum chunk upload size
481             push @invalid, sprintf 'Uploaded chunk [%d] of file [%s] is larger than it should be ([%d], allowed is [%d])',
482             $info->{flowChunkNumber},
483             $info->{flowFilename},
484             $info->{localChunkSize},
485             $self->{maxChunkSize},
486 2         22 ;
487              
488             } elsif( ! $min_max_error and $info->{ flowCurrentChunkSize } < $self->expectedChunkSize( $info ) ) {
489             # Uploaded chunk is a middle or end chunk but is too small
490             push @invalid, sprintf 'Uploaded chunk [%s] is too small ([%d]) expect [%d]',
491             $info->{flowChunkNumber},
492             $info->{flowCurrentChunkSize},
493 0         0 $self->expectedChunkSize( $info ),
494             ;
495              
496             } elsif( ! $min_max_error and $method eq 'POST' and $info->{ localChunkSize } < $info->{ flowCurrentChunkSize } ) {
497             # Real uploaded chunk is smaller than provided chunk upload size
498             push @invalid, sprintf 'Uploaded chunk [%s] is too small ([%d]) expect [%d]',
499             $info->{flowChunkNumber},
500             $info->{localChunkSize},
501             $info->{flowCurrentChunkSize},
502 1         12 ;
503              
504             } elsif( ! $min_max_error and $info->{ flowCurrentChunkSize } > $self->expectedChunkSize( $info ) ) {
505             # Uploaded chunk is a middle or end chunk but is too large
506             push @invalid, sprintf 'Uploaded chunk [%s] is too large ([%d]) expect [%d]',
507             $info->{flowChunkNumber},
508             $info->{flowCurrentChunkSize},
509 0         0 $self->expectedChunkSize( $info ),
510             ;
511              
512             } else {
513             # Everything is OK with the chunk size and file size, I guess.
514              
515             };
516              
517             @invalid
518 54         247 };
519              
520             =head2 C<< $flowJs->expectedChunkSize >>
521              
522             my $expectedSize = $flowJs->expectedChunkSize( $info, $chunkIndex );
523              
524             Returns the file size we expect for the chunk C<$chunkIndex>. The index
525             starts at 1, if it is not passed in or zero, we assume it is for the current
526             chunk as indicated by C<$info>.
527              
528             =cut
529              
530 122     122 1 188 sub expectedChunkSize( $self, $info, $index=0 ) {
  122         157  
  122         179  
  122         191  
  122         148  
531             # If we are not the last chunk, we need to be what the information says:
532 122   66     467 $index ||= $info->{flowChunkNumber};
533 122 50       404 if( ! $info->{flowTotalChunks}) {
    100          
    50          
    100          
534             # Some kind of invalid request, it'll be zero
535 0         0 return 0
536              
537             } elsif( $index != $info->{flowTotalChunks}) {
538             return $info->{flowChunkSize}
539              
540 80         448 } elsif( ! $info->{flowChunkSize} ) {
541             # No size, we guess it'll be zero:
542 0         0 return 0
543              
544             } elsif( ! $info->{flowTotalSize} ) {
545             # Total size is zero
546 2         20 return 0;
547              
548             } else {
549             # The last chunk can be smaller or sized just like all the chunks
550             # if the file size happens to be divided by the chunk size
551 40 100       133 if( $info->{flowTotalSize} % $info->{flowChunkSize}) {
552             return $info->{flowTotalSize} % $info->{flowChunkSize}
553 32         291 } else {
554             return $info->{flowChunkSize}
555 8         84 };
556             }
557             }
558              
559             =head2 C<< $flowjs->resetUploadDirectories >>
560              
561             if( $firstrun or $wipe ) {
562             $flowJs->resetUploadDirectories( $wipe )
563             };
564              
565             Creates the directory for incoming uploads. If C<$wipe>
566             is passed, it will remove all partial files from the directory.
567              
568             =cut
569              
570 0     0 1 0 sub resetUploadDirectories( $self, $wipe=undef ) {
  0         0  
  0         0  
  0         0  
571 0         0 my $dir = $self->{incomingDirectory};
572 0 0       0 if( ! -d $dir ) {
573 0 0       0 mkdir $dir
574             or return $!;
575             };
576 0 0       0 if( $wipe ) {
577 0         0 unlink glob( $dir . "/*.part" );
578             };
579              
580             }
581              
582             =head2 C<< $flowjs->chunkName >>
583              
584             my $target = $flowjs->chunkName( $info, $sessionid );
585              
586             Returns the local filename of the chunk described by C<$info> and
587             the C<$sessionid> if given. An optional index can be passed in as
588             the third parameter to get the filename of another chunk than
589             the current chunk.
590              
591             my $target = $flowjs->chunkName( $info, $sessionid, 1 );
592             # First chunk
593              
594             =cut
595              
596 76     76 1 24770 sub chunkName( $self, $info, $sessionPrefix=undef, $index=0 ) {
  76         145  
  76         144  
  76         139  
  76         113  
  76         101  
597 76         157 my $dir = $self->{incomingDirectory};
598 76 100       202 $sessionPrefix = '' unless defined $sessionPrefix;
599             my $chunkname = sprintf "%s/%s%s.part%03d",
600             $dir,
601             $sessionPrefix,
602             clean_fragment($info->{ flowIdentifier }),
603             $index || $info->{ flowChunkNumber },
604 76   66     319 ;
605 76         7253 $chunkname
606             }
607              
608             =head2 C<< $flowjs->chunkOK >>
609              
610             my( $status, @messages ) = $flowjs->chunkOK( $info, $sessionPrefix );
611             if( $status == 500 ) {
612             warn $_ for @messages;
613             return [ 500, [], [] ]
614              
615             } elsif( $status == 200 ) {
616             # That chunk exists and has the right size
617             return [ 200, [], [] ]
618              
619             } else {
620             # That chunk does not exist and should be uploaded
621             return [ 416, [],[] ]
622             }
623              
624             =cut
625              
626 29     29 1 53 sub chunkOK($self, $info, $sessionPrefix=undef, $index=0) {
  29         46  
  29         49  
  29         47  
  29         44  
  29         39  
627 29         101 my @messages = $self->validateRequest( 'GET', $info, $sessionPrefix );
628 29 50       62 if( @messages ) {
629 0         0 return 500, @messages
630             };
631              
632 29         85 my $chunkname = $self->chunkName( $info, $sessionPrefix, $index );
633 29   66     912 my $exists = -f $chunkname && -s $chunkname == $self->expectedChunkSize( $info, $index );
634 29 100       93 if( $exists ) {
635 23         63 return 200
636             } else {
637 6         24 return 416
638             }
639             }
640              
641             =head2 C<< $flowjs->uploadComplete( $info, $sessionPrefix=undef ) >>
642              
643             if( $flowjs->uploadComplete($info, $sessionPrefix) ) {
644             # do something with the chunks
645             }
646              
647             =cut
648              
649 6     6 1 5961 sub uploadComplete( $self, $info, $sessionPrefix=undef ) {
  6         14  
  6         15  
  6         13  
  6         10  
650 6         12 my $complete = 1;
651 6         55 for( 1.. $info->{ flowTotalChunks }) {
652 15         43 my( $status, @messages ) = $self->chunkOK( $info, $sessionPrefix, $_ ) ;
653 15   66     74 $complete = $complete && $status == 200 && !@messages;
654 15 100       40 if( ! $complete ) {
655             # No need to check the rest
656 3         10 last;
657             };
658             };
659 6         35 !!$complete
660             }
661              
662             =head2 C<< $flowjs->chunkFh >>
663              
664             my $fh = $flowjs->chunkFh( $info, $sessionid, $index );
665              
666             Returns an opened filehandle to the chunk described by C<$info>. The session
667             and the index are optional.
668              
669             =cut
670              
671 15     15 1 32 sub chunkFh( $self, $info, $sessionPrefix=undef, $index=0 ) {
  15         22  
  15         27  
  15         44  
  15         25  
  15         29  
672 15         149 my %info = %$info;
673 15 50       74 $info{ chunkNumber } = $index if $index;
674 15         82 my $chunkname = $self->chunkName( \%info, $sessionPrefix, $index );
675 15 50       832 open my $chunk, '<', $chunkname
676             or croak "Can't open chunk '$chunkname': $!";
677 15         58 binmode $chunk;
678 15         109 $chunk
679             }
680              
681             =head2 C<< $flowjs->chunkContent >>
682              
683             my $content = $flowjs->chunkContent( $info, $sessionid, $index );
684              
685             Returns the content of a chunk described by C<$info>. The session
686             and the index are optional.
687              
688             =cut
689              
690 4     4 1 5 sub chunkContent( $self, $info, $sessionPrefix=undef, $index=0 ) {
  4         5  
  4         8  
  4         5  
  4         7  
  4         6  
691 4         11 my $chunk = $self->chunkFh( $info, $sessionPrefix, $index );
692 4         16 local $/; # / placate Filter::Simple
693             <$chunk>
694 4         2834 }
695              
696             =head2 C<< $flowjs->disallowedContentType( $info, $session ) >>
697              
698             if( $flowjs->disallowedContentType( $info, $session )) {
699             return 415, "This type of file is not allowed";
700             };
701              
702             Checks that the subroutine validator passed in the constructor allows
703             this MIME type. Unrecognized files will be blocked.
704              
705             =cut
706              
707 8     8 1 17590 sub disallowedContentType( $self, $info, $session=undef ) {
  8         22  
  8         16  
  8         18  
  8         30  
708 8         43 my( $content_type, $image_ext ) = $self->sniffContentType($info,$session);
709 8 100       120 if( !defined $content_type ) {
    50          
    50          
710             # we need more chunks uploaded to check the content type
711             return
712              
713 1         3 } elsif( $content_type eq '' ) {
714             # We couldn't determine what the content type is?!
715 0         0 return 1
716              
717             } elsif( !$self->{allowedContentType}->( $content_type )) {
718 7   50     108 return $content_type || 1
719             } else {
720             return
721 0         0 };
722             };
723              
724             =head2 C<< $flowjs->sniffContentType( $info, $session ) >>
725              
726             my( $content_type, $image_ext ) = $flowjs->sniffContentType( $info, $session );
727             if( !defined $content_type ) {
728             # we need more chunks uploaded to check the content type
729              
730             } elsif( $content_type eq '' ) {
731             # We couldn't determine what the content type is?!
732             return 415, "This type of upload is not allowed";
733              
734             } elsif( $content_type !~ m!^image/(jpeg|png|gif)$!i ) {
735             return 415, "This type of upload is not allowed";
736              
737             } else {
738             # We allow this upload to continue, as it seems to have
739             # an appropriate content type
740             };
741              
742             This allows for finer-grained checking of the MIME-type. See also
743             the C argument in the constructor and
744             L<< ->disallowedContentType >> for a more convenient way to quickly
745             check the upload type.
746              
747             =cut
748              
749 13     13 1 5477 sub sniffContentType( $self, $info, $sessionPrefix=undef ) {
  13         126  
  13         31  
  13         27  
  13         25  
750 13         27 my( $content_type, $image_ext );
751              
752 13         59 my( $status, @messages ) = $self->chunkOK( $info, $sessionPrefix, 1 );
753 13 100       44 if( 200 == $status ) {
754 11         49 my $fh = $self->chunkFh( $info, $sessionPrefix, 1 );
755 11         61 my $t = $self->mime->mime_type($fh);
756 11 50       505645 if( $t ) {
757 11         56 $content_type = $t->mime_type;
758 11         91 $image_ext = $t->extension;
759             } else {
760 0         0 $content_type = '';
761 0         0 $image_ext = '';
762             };
763              
764             } else {
765             # Chunk 1 not uploaded/complete yet
766             }
767 13         728 return $content_type, $image_ext;
768             };
769              
770             =head2 C<< $flowjs->combineChunks $info, $sessionPrefix, $target_fh, $digest ) >>
771              
772             if( not $flowjs->uploadComplete($info, $sessionPrefix) ) {
773             print "Upload not yet completed\n";
774             return;
775             };
776              
777             open my $file, '>', 'user_upload.jpg'
778             or die "Couldn't create final file 'user_upload.jpg': $!";
779             binmode $file;
780             my $digest = Digest::SHA256->new();
781             my($ok,@unlink) = $flowjs->combineChunks( $info, undef, $file, $digest );
782             close $file;
783              
784             if( $ok ) {
785             print "Received upload OK, removing temporary upload files\n";
786             unlink @unlink;
787             print "Checksum: " . $digest->md5hex;
788             } else {
789             # whoops
790             print "Received upload failed, removing target file\n";
791             unlink 'user_upload.jpg';
792             };
793              
794             =cut
795              
796 1     1 1 333 sub combineChunks( $self, $info, $sessionPrefix, $target_fh, $digest=undef ) {
  1         2  
  1         4  
  1         3  
  1         3  
  1         4  
  1         3  
797 1         3 my @unlink_chunks;
798 1         2 my $ok = 1;
799 1         6 for( 1.. $info->{ flowTotalChunks }) {
800 4         19 my $chunkname = $self->chunkName( $info, $sessionPrefix, $_ );
801 4         9 push @unlink_chunks, $chunkname;
802              
803 4         14 my $content = $self->chunkContent( $info, $sessionPrefix, $_ );
804 4 50       19 $digest->add( $content )
805             if $digest;
806 4         7 print { $target_fh } $content;
  4         3896  
807             };
808 1         12 return $ok, @unlink_chunks
809             }
810              
811             =head2 C<< $flowjs->pendingUploads >>
812              
813             my $uploading = $flowjs->pendingUploads();
814              
815             In scalar context, returns the number of pending uploads. In list context,
816             returns the list of filenames that belong to the pending uploads. This list
817             can be larger than the number of pending uploads, as one upload can have more
818             than one chunk.
819              
820             =cut
821              
822 42     42 1 30354 sub pendingUploads( $self ) {
  42         90  
  42         118  
823 42         111 my @files;
824             my %uploads;
825              
826 42         136 my $incoming = $self->incomingDirectory;
827 42 50       5747 opendir my $dir, $incoming
828             or croak sprintf "Couldn't read incoming directory '%s': %s",
829             $self->incomingDirectory, $!;
830             @files = sort
831             map {
832 151         865 (my $upload = $_) =~ s!\.part\d+$!!;
833 151         451 $uploads{ $upload }++;
834 151         496 $_
835             }
836 235         2614 grep { -f }
837             map {
838 42         1740 "$incoming/$_"
  235         687  
839             } readdir $dir;
840              
841 42 100       1354 wantarray ? @files : scalar keys %uploads;
842             }
843              
844             =head2 C<< $flowjs->staleUploads( $timeout, $now ) >>
845              
846             my @stale_files = $flowjs->staleUploads(3600);
847              
848             In scalar context, returns the number of stale uploads. In list context,
849             returns the list of filenames that belong to the stale uploads.
850              
851             An upload is considered stale if no part of it has been written to since
852             C<$timeout> seconds ago.
853              
854             The optional C<$timeout> parameter is the minimum age of an incomplete upload
855             before it is considered stale.
856              
857             The optional C<$now> parameter is the point of reference for C<$timeout>.
858             It defaults to C
859              
860             =cut
861              
862 13     13 1 2630 sub staleUploads( $self, $timeout = 3600, $now = time ) {
  13         31  
  13         24  
  13         28  
  13         22  
863 13         25 my $cutoff = $now - $timeout;
864 13         25 my %mtime;
865 13         37 my @files = reverse sort $self->pendingUploads();
866 13         48 for ( @files ) {
867 60         279 (my $upload = $_) =~ s!\.part\d+$!!;
868 60 100 100     268 if( ! exists $mtime{ $upload } or $mtime{ $upload } < $cutoff ) {
869 30         489 my @stat = stat( $_ );
870             # We want to remember the newest instance for this upload
871 30   50     209 $mtime{ $upload } ||= 0;
872             $mtime{ $upload } = $stat[9]
873 30 100       143 if $stat[9] > $mtime{ $upload };
874             #warn "$upload: $mtime{ $upload } ($stat[9])";
875             } else {
876             #warn "$upload has already younger known participant, is not stale";
877             };
878             };
879              
880 13         30 my %stale;
881             @files = grep {
882 13         33 (my $upload = $_) =~ s!\.part\d+$!!;
  60         292  
883 60 100 66     284 if( exists $mtime{ $upload } and $mtime{ $upload } < $cutoff ) {
884 18         86 $stale{ $upload } = 1;
885             };
886             } @files;
887              
888 13 100       141 wantarray ? @files : scalar keys %stale;
889             }
890              
891             =head2 C<< $flowjs->purgeStaleOrInvalid( $timeout, $now ) >>
892              
893             my @errors = $flowjs->purgeStaleOrInvalid();
894              
895             Routine to delete all stale uploads and uploads with an invalid
896             file type.
897              
898             This is mostly a helper routine to run from a cron job.
899              
900             Note that if you allow uploads of multiple flowJs instances into the same
901             directory, they need to all have the same allowed file types or this method
902             will delete files from another instance.
903              
904             =cut
905              
906 0     0 1   sub purgeStaleOrInvalid($self, $timeout = 3600, $now = time ) {
  0            
  0            
  0            
  0            
907             # First, kill off all stale files
908 0           my @errors;
909 0           for my $f ($self->staleUploads( $timeout, $now )) {
910 0 0         unlink $f or push @errors, [$f => "$!"];
911             };
912              
913 0           for my $f ($self->pendingUploads()) {
914             # Hmm - here we need to synthesize session info from a filename
915             # not really easy, isn't it?!
916             };
917              
918             @errors
919 0           };
920              
921             1;
922              
923             =head1 REPOSITORY
924              
925             The public repository of this module is
926             L.
927              
928             =head1 SUPPORT
929              
930             The public support forum of this module is
931             L.
932              
933             =head1 BUG TRACKER
934              
935             Please report bugs in this module via the RT CPAN bug queue at
936             L
937             or via mail to L.
938              
939             =head1 AUTHOR
940              
941             Max Maischein C
942              
943             =head1 COPYRIGHT (c)
944              
945             Copyright 2009-2018 by Max Maischein C.
946              
947             =head1 LICENSE
948              
949             This module is released under the same terms as Perl itself.
950              
951             =cut