File Coverage

blib/lib/PAGI/Request/MultiPartHandler.pm
Criterion Covered Total %
statement 107 111 96.4
branch 34 44 77.2
condition 21 46 45.6
subroutine 14 15 93.3
pod 0 2 0.0
total 176 218 80.7


line stmt bran cond sub pod time code
1             package PAGI::Request::MultiPartHandler;
2 24     24   356584 use strict;
  24         32  
  24         850  
3 24     24   93 use warnings;
  24         35  
  24         1049  
4              
5 24     24   492 use Future::AsyncAwait;
  24         17200  
  24         178  
6 24     24   11106 use HTTP::MultiPartParser;
  24         56467  
  24         1525  
7 24     24   944 use Hash::MultiValue;
  24         5613  
  24         587  
8 24     24   9054 use PAGI::Request::Upload;
  24         82  
  24         1343  
9 24     24   19267 use File::Temp qw(tempfile);
  24         500475  
  24         45123  
10              
11             # Default limits
12             our $MAX_FIELD_SIZE = 1 * 1024 * 1024; # 1MB per form field (non-file parts)
13             our $MAX_FILE_SIZE = 10 * 1024 * 1024; # 10MB per file upload
14             our $SPOOL_THRESHOLD = 64 * 1024; # 64KB before spooling to disk
15             our $MAX_FILES = 20;
16             our $MAX_FIELDS = 1000;
17              
18             sub new {
19 18     18 0 398699 my ($class, %args) = @_;
20              
21             die "boundary parameter is required"
22 18 50 33     88 unless defined $args{boundary} && length $args{boundary};
23             die "receive parameter is required"
24 18 50       35 unless defined $args{receive};
25              
26             return bless {
27             boundary => $args{boundary},
28             receive => $args{receive},
29             max_field_size => $args{max_field_size} // $MAX_FIELD_SIZE,
30             max_file_size => $args{max_file_size} // $MAX_FILE_SIZE,
31             spool_threshold => $args{spool_threshold} // $SPOOL_THRESHOLD,
32             max_files => $args{max_files} // $MAX_FILES,
33             max_fields => $args{max_fields} // $MAX_FIELDS,
34 18   66     331 temp_dir => $args{temp_dir} // $ENV{TMPDIR} // '/tmp',
      66        
      33        
      66        
      66        
      33        
      50        
35             }, $class;
36             }
37              
38 18     18 0 158 async sub parse {
39 18         16 my $self = shift;
40              
41 18         34 my @form_pairs;
42             my @upload_pairs;
43 18         0 my @temp_files; # Track for cleanup on error
44 18         18 my $file_count = 0;
45 18         17 my $field_count = 0;
46              
47             # Cleanup handler for error cases
48             my $cleanup = sub {
49 5     5   10 for my $path (@temp_files) {
50 0 0 0     0 unlink $path if $path && -f $path;
51             }
52 18         45 };
53              
54             # Current part state
55 18         21 my $current_headers;
56 18         20 my $current_data = '';
57 18         27 my $current_fh;
58             my $current_temp_path;
59 18         17 my $current_size = 0;
60 18         16 my $current_is_file = 0; # Track if current part is a file upload
61              
62             my $finish_part = sub {
63 47 100   47   73 return unless $current_headers;
64              
65 29         47 my $disposition = _parse_content_disposition($current_headers);
66 29   50     50 my $name = $disposition->{name} // '';
67 29         39 my $filename = $disposition->{filename};
68 29   100     58 my $content_type = $current_headers->{'content-type'} // 'text/plain';
69              
70 29 100       38 if (defined $filename) {
71             # File upload
72 17         17 $file_count++;
73             die "Too many files (max $self->{max_files})"
74 17 100       64 if $file_count > $self->{max_files};
75              
76 16         15 my $upload;
77 16 100       23 if ($current_fh) {
78 1         34 close $current_fh;
79 1         8 $upload = PAGI::Request::Upload->new(
80             field_name => $name,
81             filename => $filename,
82             content_type => $content_type,
83             temp_path => $current_temp_path,
84             size => $current_size,
85             );
86             } else {
87 15         56 $upload = PAGI::Request::Upload->new(
88             field_name => $name,
89             filename => $filename,
90             content_type => $content_type,
91             data => $current_data,
92             );
93             }
94 16         52 push @upload_pairs, $name, $upload;
95             } else {
96             # Regular form field
97 12         14 $field_count++;
98             die "Too many fields (max $self->{max_fields})"
99 12 100       51 if $field_count > $self->{max_fields};
100              
101 11         20 push @form_pairs, $name, $current_data;
102             }
103              
104             # Reset state
105 27         40 $current_headers = undef;
106 27         29 $current_data = '';
107 27         25 $current_fh = undef;
108 27         19 $current_temp_path = undef;
109 27         39 $current_size = 0;
110 27         491 $current_is_file = 0;
111 18         71 };
112              
113             # Wrap parsing in eval for cleanup on error
114 18         19 eval {
115             my $parser = HTTP::MultiPartParser->new(
116             boundary => $self->{boundary},
117              
118             on_header => sub {
119 32     32   1199 my ($headers) = @_;
120 32         51 $finish_part->(); # Finish previous part if any
121              
122             # Parse headers into hash - $headers is an arrayref of header lines
123 32         29 $current_headers = {};
124 32         60 for my $line (@$headers) {
125 47 50       135 if ($line =~ /^([^:]+):\s*(.*)$/) {
126 47         145 $current_headers->{lc($1)} = $2;
127             }
128             }
129              
130             # Detect if this part is a file upload (has filename in Content-Disposition)
131 32   50     72 my $cd = $current_headers->{'content-disposition'} // '';
132 32 100       127 $current_is_file = ($cd =~ /filename=/i) ? 1 : 0;
133             },
134              
135             on_body => sub {
136 32     32   514 my ($chunk) = @_;
137 32         31 $current_size += length($chunk);
138              
139             # Use different size limits for files vs form fields
140             my $max_size = $current_is_file
141             ? $self->{max_file_size}
142 32 100       52 : $self->{max_field_size};
143 32 100       42 my $part_type = $current_is_file ? 'File upload' : 'Form field';
144 32 100       197 die "$part_type too large (max $max_size bytes)"
145             if $current_size > $max_size;
146              
147             # Check if we need to spool to disk
148 29 100 66     83 if (!$current_fh && $current_size > $self->{spool_threshold}) {
149             # Spool to temp file
150             ($current_fh, $current_temp_path) = tempfile(
151             DIR => $self->{temp_dir},
152 1         6 UNLINK => 0,
153             );
154 1         2553 push @temp_files, $current_temp_path; # Track for cleanup
155 1         2 binmode($current_fh);
156 1 50       3 print $current_fh $current_data
157             or die "Failed to write to temp file: $!";
158 1         3 $current_data = '';
159             }
160              
161 29 100       42 if ($current_fh) {
162 1 50       134 print $current_fh $chunk
163             or die "Failed to write to temp file: $!";
164             } else {
165 28         45 $current_data .= $chunk;
166             }
167             },
168              
169             on_error => sub {
170 0     0   0 my ($error) = @_;
171 0         0 die "Multipart parse error: $error";
172             },
173 18         175 );
174              
175             # Feed chunks from receive
176 18         1362 my $receive = $self->{receive};
177 18         16 while (1) {
178 25         41 my $message = await $receive->();
179 25 50 33     1241 last unless $message && $message->{type};
180 25 50       46 last if $message->{type} eq 'http.disconnect';
181              
182 25 50 33     72 if (defined $message->{body} && length $message->{body}) {
183 25         56 $parser->parse($message->{body});
184             }
185              
186 22 100       327 last unless $message->{more};
187             }
188              
189 15         37 $parser->finish;
190 15         148 $finish_part->(); # Handle last part
191             };
192 18 100       49 if (my $err = $@) {
193 5         9 $cleanup->();
194 5         70 die $err;
195             }
196              
197             return (
198 13         56 Hash::MultiValue->new(@form_pairs),
199             Hash::MultiValue->new(@upload_pairs),
200             );
201             }
202              
203             sub _parse_content_disposition {
204 29     29   31 my ($headers) = @_;
205 29   50     47 my $cd = $headers->{'content-disposition'} // '';
206              
207 29         29 my %result;
208              
209             # Parse name="value" pairs
210 29         129 while ($cd =~ /(\w+)="([^"]*)"/g) {
211 46         131 $result{$1} = $2;
212             }
213             # Also handle unquoted values
214 29         104 while ($cd =~ /(\w+)=([^;\s"]+)/g) {
215 0   0     0 $result{$1} //= $2;
216             }
217              
218 29         60 return \%result;
219             }
220              
221             1;
222              
223             __END__