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