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   325627 use strict;
  24         37  
  24         796  
3 24     24   83 use warnings;
  24         39  
  24         1002  
4              
5 24     24   559 use Future::AsyncAwait;
  24         18723  
  24         139  
6 24     24   10131 use HTTP::MultiPartParser;
  24         53440  
  24         1452  
7 24     24   937 use Hash::MultiValue;
  24         5327  
  24         678  
8 24     24   8935 use PAGI::Request::Upload;
  24         94  
  24         1264  
9 24     24   18983 use File::Temp qw(tempfile);
  24         494700  
  24         45291  
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 422961 my ($class, %args) = @_;
20              
21             die "boundary parameter is required"
22 18 50 33     111 unless defined $args{boundary} && length $args{boundary};
23             die "receive parameter is required"
24 18 50       47 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     389 temp_dir => $args{temp_dir} // $ENV{TMPDIR} // '/tmp',
      66        
      33        
      66        
      66        
      33        
      50        
35             }, $class;
36             }
37              
38 18     18 0 202 async sub parse {
39 18         23 my $self = shift;
40              
41 18         43 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         25 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         56 };
53              
54             # Current part state
55 18         26 my $current_headers;
56 18         26 my $current_data = '';
57 18         33 my $current_fh;
58             my $current_temp_path;
59 18         22 my $current_size = 0;
60 18         21 my $current_is_file = 0; # Track if current part is a file upload
61              
62             my $finish_part = sub {
63 47 100   47   80 return unless $current_headers;
64              
65 29         58 my $disposition = _parse_content_disposition($current_headers);
66 29   50     65 my $name = $disposition->{name} // '';
67 29         38 my $filename = $disposition->{filename};
68 29   100     69 my $content_type = $current_headers->{'content-type'} // 'text/plain';
69              
70 29 100       49 if (defined $filename) {
71             # File upload
72 17         21 $file_count++;
73             die "Too many files (max $self->{max_files})"
74 17 100       78 if $file_count > $self->{max_files};
75              
76 16         20 my $upload;
77 16 100       29 if ($current_fh) {
78 1         36 close $current_fh;
79 1         10 $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         70 $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         28 push @upload_pairs, $name, $upload;
95             } else {
96             # Regular form field
97 12         13 $field_count++;
98             die "Too many fields (max $self->{max_fields})"
99 12 100       57 if $field_count > $self->{max_fields};
100              
101 11         20 push @form_pairs, $name, $current_data;
102             }
103              
104             # Reset state
105 27         49 $current_headers = undef;
106 27         35 $current_data = '';
107 27         32 $current_fh = undef;
108 27         28 $current_temp_path = undef;
109 27         25 $current_size = 0;
110 27         591 $current_is_file = 0;
111 18         88 };
112              
113             # Wrap parsing in eval for cleanup on error
114 18         25 eval {
115             my $parser = HTTP::MultiPartParser->new(
116             boundary => $self->{boundary},
117              
118             on_header => sub {
119 32     32   1314 my ($headers) = @_;
120 32         55 $finish_part->(); # Finish previous part if any
121              
122             # Parse headers into hash - $headers is an arrayref of header lines
123 32         35 $current_headers = {};
124 32         55 for my $line (@$headers) {
125 47 50       189 if ($line =~ /^([^:]+):\s*(.*)$/) {
126 47         176 $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     68 my $cd = $current_headers->{'content-disposition'} // '';
132 32 100       152 $current_is_file = ($cd =~ /filename=/i) ? 1 : 0;
133             },
134              
135             on_body => sub {
136 32     32   573 my ($chunk) = @_;
137 32         32 $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       63 : $self->{max_field_size};
143 32 100       45 my $part_type = $current_is_file ? 'File upload' : 'Form field';
144 32 100       208 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     97 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         7 UNLINK => 0,
153             );
154 1         848 push @temp_files, $current_temp_path; # Track for cleanup
155 1         3 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       75 if ($current_fh) {
162 1 50       125 print $current_fh $chunk
163             or die "Failed to write to temp file: $!";
164             } else {
165 28         56 $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         209 );
174              
175             # Feed chunks from receive
176 18         1689 my $receive = $self->{receive};
177 18         45 while (1) {
178 25         49 my $message = await $receive->();
179 25 50 33     1313 last unless $message && $message->{type};
180 25 50       54 last if $message->{type} eq 'http.disconnect';
181              
182 25 50 33     83 if (defined $message->{body} && length $message->{body}) {
183 25         61 $parser->parse($message->{body});
184             }
185              
186 22 100       342 last unless $message->{more};
187             }
188              
189 15         44 $parser->finish;
190 15         165 $finish_part->(); # Handle last part
191             };
192 18 100       54 if (my $err = $@) {
193 5         12 $cleanup->();
194 5         86 die $err;
195             }
196              
197             return (
198 13         69 Hash::MultiValue->new(@form_pairs),
199             Hash::MultiValue->new(@upload_pairs),
200             );
201             }
202              
203             sub _parse_content_disposition {
204 29     29   34 my ($headers) = @_;
205 29   50     56 my $cd = $headers->{'content-disposition'} // '';
206              
207 29         29 my %result;
208              
209             # Parse name="value" pairs
210 29         147 while ($cd =~ /(\w+)="([^"]*)"/g) {
211 46         199 $result{$1} = $2;
212             }
213             # Also handle unquoted values
214 29         172 while ($cd =~ /(\w+)=([^;\s"]+)/g) {
215 0   0     0 $result{$1} //= $2;
216             }
217              
218 29         72 return \%result;
219             }
220              
221             1;
222              
223             __END__