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.002001';
3 1     1   478 use strict;
  1         2  
  1         31  
4 1     1   3 use warnings;
  1         2  
  1         38  
5 1     1   3 use parent 'PAGI::Middleware';
  1         1  
  1         4  
6 1     1   49 use Future::AsyncAwait;
  1         1  
  1         5  
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   9 my ($self, $config) = @_;
48              
49 4   50     21 $self->{max_size} = $config->{max_size} // 1024 * 1024; # 1MB
50             }
51              
52             sub wrap {
53 4     4 1 29 my ($self, $app) = @_;
54              
55 4     4   78 return async sub {
56 4         7 my ($scope, $receive, $send) = @_;
57 4 50       10 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     43 my $content_type = $self->_get_header($scope, 'content-type') // '';
64 4         17 my $is_form = $content_type =~ m{^application/x-www-form-urlencoded}i;
65              
66 4 100       10 unless ($is_form) {
67 1         25 await $app->($scope, $receive, $send);
68 1         89 return;
69             }
70              
71             # Read body
72 3         4 my $body = '';
73 3         4 my $too_large = 0;
74              
75 3         3 while (1) {
76 3         7 my $event = await $receive->();
77 3 50 33     129 last unless $event && $event->{type};
78              
79 3 50       7 if ($event->{type} eq 'http.request') {
    0          
80 3   50     8 $body .= $event->{body} // '';
81 3 50       10 if (length($body) > $self->{max_size}) {
82 0         0 $too_large = 1;
83 0         0 last;
84             }
85 3 50       8 last unless $event->{more};
86             }
87             elsif ($event->{type} eq 'http.disconnect') {
88 0         0 last;
89             }
90             }
91              
92 3 50       7 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         4 my $parsed = $self->_parse_urlencoded($body);
99              
100             # Create modified scope with parsed body
101 3         12 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         12 };
110              
111 3         7 await $app->($new_scope, $empty_receive, $send);
112 4         17 };
113             }
114              
115             sub _parse_urlencoded {
116 3     3   7 my ($self, $body) = @_;
117              
118 3         3 my %result;
119              
120 3         8 for my $pair (split /&/, $body) {
121 8         16 my ($key, $value) = split /=/, $pair, 2;
122 8 50       13 next unless defined $key;
123              
124 8         11 $key = $self->_url_decode($key);
125 8 50       13 $value = defined $value ? $self->_url_decode($value) : '';
126              
127             # Handle multiple values for same key
128 8 100       13 if (exists $result{$key}) {
129 2 100       6 if (ref $result{$key} eq 'ARRAY') {
130 1         1 push @{$result{$key}}, $value;
  1         3  
131             } else {
132 1         3 $result{$key} = [$result{$key}, $value];
133             }
134             } else {
135 6         9 $result{$key} = $value;
136             }
137             }
138              
139 3         7 return \%result;
140             }
141              
142             sub _url_decode {
143 16     16   21 my ($self, $str) = @_;
144              
145 16         18 $str =~ s/\+/ /g;
146 16         22 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
  3         8  
147 16         22 return $str;
148             }
149              
150             sub _get_header {
151 4     4   8 my ($self, $scope, $name) = @_;
152              
153 4         27 $name = lc($name);
154 4   50     5 for my $h (@{$scope->{headers} // []}) {
  4         13  
155 4 50       15 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__