File Coverage

blib/lib/PAGI/Middleware/HTTPSRedirect.pm
Criterion Covered Total %
statement 53 68 77.9
branch 10 20 50.0
condition 10 21 47.6
subroutine 10 10 100.0
pod 1 1 100.0
total 84 120 70.0


line stmt bran cond sub pod time code
1             package PAGI::Middleware::HTTPSRedirect;
2              
3 1     1   477 use strict;
  1         1  
  1         30  
4 1     1   4 use warnings;
  1         1  
  1         38  
5 1     1   3 use parent 'PAGI::Middleware';
  1         1  
  1         5  
6 1     1   44 use Future::AsyncAwait;
  1         2  
  1         5  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::HTTPSRedirect - Force HTTPS redirect middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'HTTPSRedirect';
18             $my_app;
19             };
20              
21             =head1 DESCRIPTION
22              
23             PAGI::Middleware::HTTPSRedirect redirects HTTP requests to HTTPS.
24             Useful for enforcing secure connections in production.
25              
26             =head1 CONFIGURATION
27              
28             =over 4
29              
30             =item * redirect_code (default: 301)
31              
32             HTTP status code for redirects. Use 302 for temporary redirects.
33              
34             =item * exclude (optional)
35              
36             Arrayref of paths to exclude from redirect (e.g., health checks).
37              
38             =item * hsts (default: 0)
39              
40             If true, add Strict-Transport-Security header.
41              
42             =item * hsts_max_age (default: 31536000)
43              
44             HSTS max-age in seconds (1 year default).
45              
46             =back
47              
48             =cut
49              
50             sub _init {
51 3     3   5 my ($self, $config) = @_;
52              
53 3   50     15 $self->{redirect_code} = $config->{redirect_code} // 301;
54 3   100     11 $self->{exclude} = $config->{exclude} // [];
55 3   50     8 $self->{hsts} = $config->{hsts} // 0;
56 3   50     11 $self->{hsts_max_age} = $config->{hsts_max_age} // 31536000;
57             }
58              
59             sub wrap {
60 3     3 1 21 my ($self, $app) = @_;
61              
62 3     3   70 return async sub {
63 3         5 my ($scope, $receive, $send) = @_;
64 3 50       8 if ($scope->{type} ne 'http') {
65 0         0 await $app->($scope, $receive, $send);
66 0         0 return;
67             }
68              
69 3   50     42 my $scheme = $scope->{scheme} // 'http';
70              
71             # Already HTTPS
72 3 100       8 if ($scheme eq 'https') {
73             # Add HSTS header if enabled
74 1 50       4 if ($self->{hsts}) {
75 0         0 my $wrapped_send = async sub {
76 0         0 my ($event) = @_;
77 0 0       0 if ($event->{type} eq 'http.response.start') {
78 0   0     0 my @headers = @{$event->{headers} // []};
  0         0  
79 0         0 push @headers, [
80             'Strict-Transport-Security',
81             "max-age=$self->{hsts_max_age}; includeSubDomains"
82             ];
83 0         0 await $send->({
84             %$event,
85             headers => \@headers,
86             });
87 0         0 return;
88             }
89 0         0 await $send->($event);
90 0         0 };
91 0         0 await $app->($scope, $receive, $wrapped_send);
92             } else {
93 1         2 await $app->($scope, $receive, $send);
94             }
95 1         90 return;
96             }
97              
98             # Check exclusions
99 2 100       5 if ($self->_is_excluded($scope->{path})) {
100 1         3 await $app->($scope, $receive, $send);
101 1         90 return;
102             }
103              
104             # Build HTTPS URL
105 1   50     4 my $host = $self->_get_header($scope, 'host') // 'localhost';
106 1   50     3 my $path = $scope->{path} // '/';
107 1         2 my $query = $scope->{query_string};
108              
109 1         2 my $url = "https://$host$path";
110 1 50 33     3 $url .= "?$query" if defined $query && $query ne '';
111              
112 1         4 await $self->_send_redirect($send, $url);
113 3         13 };
114             }
115              
116             sub _is_excluded {
117 2     2   3 my ($self, $path) = @_;
118              
119 2         3 for my $pattern (@{$self->{exclude}}) {
  2         5  
120 1 50       4 if (ref $pattern eq 'Regexp') {
121 0 0       0 return 1 if $path =~ $pattern;
122             } else {
123 1 50       5 return 1 if $path eq $pattern;
124             }
125             }
126 1         3 return 0;
127             }
128              
129 1     1   1 async sub _send_redirect {
130 1         2 my ($self, $send, $location) = @_;
131              
132 1         2 my $status = $self->{redirect_code};
133 1         1 my $body = "Redirecting to $location";
134              
135 1         7 await $send->({
136             type => 'http.response.start',
137             status => $status,
138             headers => [
139             ['Content-Type', 'text/plain'],
140             ['Content-Length', length($body)],
141             ['Location', $location],
142             ],
143             });
144 1         44 await $send->({
145             type => 'http.response.body',
146             body => $body,
147             more => 0,
148             });
149             }
150              
151             sub _get_header {
152 1     1   3 my ($self, $scope, $name) = @_;
153              
154 1         2 $name = lc($name);
155 1   50     2 for my $h (@{$scope->{headers} // []}) {
  1         3  
156 1 50       7 return $h->[1] if lc($h->[0]) eq $name;
157             }
158 0           return;
159             }
160              
161             1;
162              
163             __END__