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              
3 1     1   480 use strict;
  1         1  
  1         34  
4 1     1   4 use warnings;
  1         1  
  1         37  
5 1     1   4 use parent 'PAGI::Middleware';
  1         1  
  1         4  
6 1     1   44 use Future::AsyncAwait;
  1         1  
  1         10  
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 28 my ($self, $app) = @_;
54              
55 4     4   79 return async sub {
56 4         7 my ($scope, $receive, $send) = @_;
57 4 50       11 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     8 my $content_type = $self->_get_header($scope, 'content-type') // '';
64 4         47 my $is_form = $content_type =~ m{^application/x-www-form-urlencoded}i;
65              
66 4 100       10 unless ($is_form) {
67 1         2 await $app->($scope, $receive, $send);
68 1         126 return;
69             }
70              
71             # Read body
72 3         5 my $body = '';
73 3         3 my $too_large = 0;
74              
75 3         4 while (1) {
76 3         6 my $event = await $receive->();
77 3 50 33     162 last unless $event && $event->{type};
78              
79 3 50       7 if ($event->{type} eq 'http.request') {
    0          
80 3   50     10 $body .= $event->{body} // '';
81 3 50       7 if (length($body) > $self->{max_size}) {
82 0         0 $too_large = 1;
83 0         0 last;
84             }
85 3 50       9 last unless $event->{more};
86             }
87             elsif ($event->{type} eq 'http.disconnect') {
88 0         0 last;
89             }
90             }
91              
92 3 50       4 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         7 my $parsed = $self->_parse_urlencoded($body);
99              
100             # Create modified scope with parsed body
101 3         15 my $new_scope = {
102             %$scope,
103             'pagi.parsed_body' => $parsed,
104             'pagi.raw_body' => $body,
105             };
106              
107             # Create a receive that returns empty (body already consumed)
108 0         0 my $empty_receive = async sub {
109 0         0 return { type => 'http.request', body => '', more => 0 };
110 3         8 };
111              
112 3         8 await $app->($new_scope, $empty_receive, $send);
113 4         16 };
114             }
115              
116             sub _parse_urlencoded {
117 3     3   5 my ($self, $body) = @_;
118              
119 3         5 my %result;
120              
121 3         8 for my $pair (split /&/, $body) {
122 8         14 my ($key, $value) = split /=/, $pair, 2;
123 8 50       14 next unless defined $key;
124              
125 8         11 $key = $self->_url_decode($key);
126 8 50       12 $value = defined $value ? $self->_url_decode($value) : '';
127              
128             # Handle multiple values for same key
129 8 100       14 if (exists $result{$key}) {
130 2 100       6 if (ref $result{$key} eq 'ARRAY') {
131 1         2 push @{$result{$key}}, $value;
  1         3  
132             } else {
133 1         3 $result{$key} = [$result{$key}, $value];
134             }
135             } else {
136 6         12 $result{$key} = $value;
137             }
138             }
139              
140 3         6 return \%result;
141             }
142              
143             sub _url_decode {
144 16     16   18 my ($self, $str) = @_;
145              
146 16         20 $str =~ s/\+/ /g;
147 16         20 $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
  3         10  
148 16         21 return $str;
149             }
150              
151             sub _get_header {
152 4     4   6 my ($self, $scope, $name) = @_;
153              
154 4         8 $name = lc($name);
155 4   50     4 for my $h (@{$scope->{headers} // []}) {
  4         10  
156 4 50       15 return $h->[1] if lc($h->[0]) eq $name;
157             }
158 0           return;
159             }
160              
161 0     0     async sub _send_error {
162 0           my ($self, $send, $status, $message) = @_;
163              
164 0           await $send->({
165             type => 'http.response.start',
166             status => $status,
167             headers => [
168             ['Content-Type', 'text/plain'],
169             ['Content-Length', length($message)],
170             ],
171             });
172 0           await $send->({
173             type => 'http.response.body',
174             body => $message,
175             more => 0,
176             });
177             }
178              
179             1;
180              
181             __END__