File Coverage

blib/lib/PAGI/Middleware/Maintenance.pm
Criterion Covered Total %
statement 53 74 71.6
branch 12 34 35.2
condition 9 27 33.3
subroutine 10 13 76.9
pod 1 1 100.0
total 85 149 57.0


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Maintenance;
2              
3 1     1   584 use strict;
  1         2  
  1         50  
4 1     1   5 use warnings;
  1         2  
  1         61  
5 1     1   5 use parent 'PAGI::Middleware';
  1         1  
  1         7  
6 1     1   77 use Future::AsyncAwait;
  1         2  
  1         8  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::Maintenance - Serve maintenance page when enabled
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'Maintenance',
18             enabled => $ENV{MAINTENANCE_MODE},
19             bypass_ips => ['10.0.0.0/8'],
20             retry_after => 3600;
21             $my_app;
22             };
23              
24             =head1 DESCRIPTION
25              
26             PAGI::Middleware::Maintenance serves a 503 Service Unavailable page
27             when maintenance mode is enabled. Supports IP-based bypass for admins.
28              
29             =head1 CONFIGURATION
30              
31             =over 4
32              
33             =item * enabled (default: 0)
34              
35             Enable maintenance mode. Can be a coderef for dynamic checking.
36              
37             =item * bypass_ips (default: [])
38              
39             Arrayref of IPs or CIDR ranges that bypass maintenance mode.
40              
41             =item * bypass_paths (default: [])
42              
43             Arrayref of paths that bypass maintenance mode (e.g., health checks).
44              
45             =item * retry_after (optional)
46              
47             Seconds until maintenance expected to end. Sets Retry-After header.
48              
49             =item * content_type (default: 'text/html')
50              
51             Content-Type of the maintenance page.
52              
53             =item * body (default: built-in HTML page)
54              
55             Custom maintenance page body.
56              
57             =back
58              
59             =cut
60              
61             sub _init {
62 4     4   7 my ($self, $config) = @_;
63              
64 4   50     20 $self->{enabled} = $config->{enabled} // 0;
65 4   100     14 $self->{bypass_ips} = $config->{bypass_ips} // [];
66 4   100     12 $self->{bypass_paths} = $config->{bypass_paths} // [];
67 4         6 $self->{retry_after} = $config->{retry_after};
68 4   50     13 $self->{content_type} = $config->{content_type} // 'text/html';
69 4   33     12 $self->{body} = $config->{body} // $self->_default_body();
70             }
71              
72             sub wrap {
73 4     4 1 26 my ($self, $app) = @_;
74              
75 4     4   82 return async sub {
76 4         8 my ($scope, $receive, $send) = @_;
77 4 50       11 if ($scope->{type} ne 'http') {
78 0         0 await $app->($scope, $receive, $send);
79 0         0 return;
80             }
81              
82             # Check if maintenance is enabled
83             my $enabled = ref $self->{enabled} eq 'CODE'
84             ? $self->{enabled}->()
85 4 50       24 : $self->{enabled};
86              
87 4 100       9 unless ($enabled) {
88 1         2 await $app->($scope, $receive, $send);
89 1         88 return;
90             }
91              
92             # Check bypass conditions
93 3 100       8 if ($self->_should_bypass($scope)) {
94 2         4 await $app->($scope, $receive, $send);
95 2         207 return;
96             }
97              
98             # Serve maintenance page
99 1         3 await $self->_send_maintenance($send);
100 4         15 };
101             }
102              
103             sub _should_bypass {
104 3     3   4 my ($self, $scope) = @_;
105              
106             # Check bypass paths
107 3   50     7 my $path = $scope->{path} // '';
108 3         4 for my $bypass_path (@{$self->{bypass_paths}}) {
  3         7  
109 1 50       5 if (ref $bypass_path eq 'Regexp') {
110 0 0       0 return 1 if $path =~ $bypass_path;
111             } else {
112 1 50       5 return 1 if $path eq $bypass_path;
113             }
114             }
115              
116             # Check bypass IPs
117 2 50 50     7 my $client_ip = exists $scope->{client} ? ($scope->{client}[0] // '') : '';
118 2         2 for my $bypass_ip (@{$self->{bypass_ips}}) {
  2         4  
119 1 50       5 if ($bypass_ip =~ m{/}) {
120             # CIDR notation
121 0 0       0 return 1 if $self->_ip_in_cidr($client_ip, $bypass_ip);
122             } else {
123             # Exact match
124 1 50       4 return 1 if $client_ip eq $bypass_ip;
125             }
126             }
127              
128 1         2 return 0;
129             }
130              
131             sub _ip_in_cidr {
132 0     0   0 my ($self, $ip, $cidr) = @_;
133              
134 0         0 my ($network, $bits) = split m{/}, $cidr;
135              
136             # Simple IPv4 check
137 0 0 0     0 return 0 unless $ip =~ /^[\d.]+$/ && $network =~ /^[\d.]+$/;
138              
139 0         0 my $ip_num = $self->_ip_to_num($ip);
140 0         0 my $net_num = $self->_ip_to_num($network);
141              
142 0 0 0     0 return 0 unless defined $ip_num && defined $net_num;
143              
144 0         0 my $mask = ~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF;
145 0         0 return ($ip_num & $mask) == ($net_num & $mask);
146             }
147              
148             sub _ip_to_num {
149 0     0   0 my ($self, $ip) = @_;
150              
151 0         0 my @octets = split /\./, $ip;
152 0 0       0 return unless @octets == 4;
153 0 0       0 return unless _all_valid_octets(@octets);
154 0         0 return ($octets[0] << 24) + ($octets[1] << 16) + ($octets[2] << 8) + $octets[3];
155             }
156              
157             sub _all_valid_octets {
158 0     0   0 for (@_) {
159 0 0 0     0 return 0 unless /^\d+$/ && $_ >= 0 && $_ <= 255;
      0        
160             }
161 0         0 return 1;
162             }
163              
164 1     1   2 async sub _send_maintenance {
165 1         2 my ($self, $send) = @_;
166              
167 1         2 my $body = $self->{body};
168              
169             my @headers = (
170 1         4 ['Content-Type', $self->{content_type}],
171             ['Content-Length', length($body)],
172             );
173              
174 1 50       3 if (defined $self->{retry_after}) {
175 0         0 push @headers, ['Retry-After', $self->{retry_after}];
176             }
177              
178             await $send->({
179             type => 'http.response.start',
180             status => 503,
181             headers => \@headers,
182 1         4 });
183 1         41 await $send->({
184             type => 'http.response.body',
185             body => $body,
186             more => 0,
187             });
188             }
189              
190             sub _default_body {
191 4     4   8 my ($self) = @_;
192              
193 4         12 return <<'HTML';
194            
195            
196            
197            
198            
199             Maintenance
200            
222            
223            
224            
225            
🔧
226            

Under Maintenance

227            

We're currently performing scheduled maintenance. Please check back soon.

228            
229            
230            
231             HTML
232             }
233              
234             1;
235              
236             __END__