File Coverage

blib/lib/PAGI/Middleware/FormBody.pm
Criterion Covered Total %
statement 62 76 81.5
branch 15 26 57.6
condition 5 11 45.4
subroutine 10 11 90.9
pod 1 1 100.0
total 93 125 74.4


line stmt bran cond sub pod time code
1             package PAGI::Middleware::FormBody;
2             $PAGI::Middleware::FormBody::VERSION = '0.002000';
3 1     1   2359 use strict;
  1         3  
  1         70  
4 1     1   7 use warnings;
  1         2  
  1         111  
5 1     1   9 use parent 'PAGI::Middleware';
  1         2  
  1         11  
6 1     1   137 use Future::AsyncAwait;
  1         2  
  1         16  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::FormBody - Form request body parsing middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'FormBody';
18             $my_app;
19             };
20              
21             # In your app:
22             async sub app {
23             my ($scope, $receive, $send) = @_;
24              
25             my $form_data = $scope->{pagi.parsed_body};
26             # $form_data is a hashref like { name => 'value', ... }
27             }
28              
29             =head1 DESCRIPTION
30              
31             PAGI::Middleware::FormBody parses URL-encoded form request bodies and
32             makes the parsed data available in C<< $scope->{'pagi.parsed_body'} >>.
33              
34             =head1 CONFIGURATION
35              
36             =over 4
37              
38             =item * max_size (default: 1MB)
39              
40             Maximum body size to parse (in bytes).
41              
42             =back
43              
44             =cut
45              
46             sub _init {
47 4     4   13 my ($self, $config) = @_;
48              
49 4   50     46 $self->{max_size} = $config->{max_size} // 1024 * 1024; # 1MB
50             }
51              
52             sub wrap {
53 4     4 1 53 my ($self, $app) = @_;
54              
55 4     4   156 return async sub {
56 4         14 my ($scope, $receive, $send) = @_;
57 4 50       369 if ($scope->{type} ne 'http') {
58 0         0 await $app->($scope, $receive, $send);
59 0         0 return;
60             }
61              
62             # Check content type
63 4   50     22 my $content_type = $self->_get_header($scope, 'content-type') // '';
64 4         32 my $is_form = $content_type =~ m{^application/x-www-form-urlencoded}i;
65              
66 4 100       13 unless ($is_form) {
67 1         5 await $app->($scope, $receive, $send);
68 1         270 return;
69             }
70              
71             # Read body
72 3         8 my $body = '';
73 3         7 my $too_large = 0;
74              
75 3         5 while (1) {
76 3         10 my $event = await $receive->();
77 3 50 33     301 last unless $event && $event->{type};
78              
79 3 50       15 if ($event->{type} eq 'http.request') {
    0          
80 3   50     16 $body .= $event->{body} // '';
81 3 50       15 if (length($body) > $self->{max_size}) {
82 0         0 $too_large = 1;
83 0         0 last;
84             }
85 3 50       14 last unless $event->{more};
86             }
87             elsif ($event->{type} eq 'http.disconnect') {
88 0         0 last;
89             }
90             }
91              
92 3 50       11 if ($too_large) {
93 0         0 await $self->_send_error($send, 413, 'Request body too large');
94 0         0 return;
95             }
96              
97             # Parse form data
98 3         13 my $parsed = $self->_parse_urlencoded($body);
99              
100             # Create modified scope with parsed body
101 3         30 my $new_scope = $self->modify_scope($scope, {
102             'pagi.parsed_body' => $parsed,
103             'pagi.raw_body' => $body,
104             });
105              
106             # Create a receive that returns empty (body already consumed)
107 0         0 my $empty_receive = async sub {
108 0         0 return { type => 'http.request', body => '', more => 0 };
109 3         17 };
110              
111 3         13 await $app->($new_scope, $empty_receive, $send);
112 4         35 };
113             }
114              
115             sub _parse_urlencoded {
116 3     3   9 my ($self, $body) = @_;
117              
118 3         6 my %result;
119              
120 3         16 for my $pair (split /&/, $body) {
121 8         28 my ($key, $value) = split /=/, $pair, 2;
122 8 50       41 next unless defined $key;
123              
124 8         18 $key = $self->_url_decode($key);
125 8 50       22 $value = defined $value ? $self->_url_decode($value) : '';
126              
127             # Handle multiple values for same key
128 8 100       19 if (exists $result{$key}) {
129 2 100       10 if (ref $result{$key} eq 'ARRAY') {
130 1         3 push @{$result{$key}}, $value;
  1         6  
131             } else {
132 1         5 $result{$key} = [$result{$key}, $value];
133             }
134             } else {
135 6         19 $result{$key} = $value;
136             }
137             }
138              
139 3         10 return \%result;
140             }
141              
142             sub _url_decode {
143 16     16   74 my ($self, $str) = @_;
144              
145 16         34 $str =~ s/\+/ /g;
146 16         33 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
  3         11  
147 16         37 return $str;
148             }
149              
150             sub _get_header {
151 4     4   14 my ($self, $scope, $name) = @_;
152              
153 4         13 $name = lc($name);
154 4   50     9 for my $h (@{$scope->{headers} // []}) {
  4         23  
155 4 50       34 return $h->[1] if lc($h->[0]) eq $name;
156             }
157 0           return;
158             }
159              
160 0     0     async sub _send_error {
161 0           my ($self, $send, $status, $message) = @_;
162              
163 0           await $send->({
164             type => 'http.response.start',
165             status => $status,
166             headers => [
167             ['Content-Type', 'text/plain'],
168             ['Content-Length', length($message)],
169             ],
170             });
171 0           await $send->({
172             type => 'http.response.body',
173             body => $message,
174             more => 0,
175             });
176             }
177              
178             1;
179              
180             __END__