File Coverage

lib/PAGI/Middleware/XSendfile.pm
Criterion Covered Total %
statement 73 75 97.3
branch 29 34 85.2
condition 10 14 71.4
subroutine 10 10 100.0
pod 1 1 100.0
total 123 134 91.7


line stmt bran cond sub pod time code
1             package PAGI::Middleware::XSendfile;
2              
3 1     1   204492 use strict;
  1         2  
  1         36  
4 1     1   3 use warnings;
  1         5  
  1         44  
5 1     1   373 use parent 'PAGI::Middleware';
  1         304  
  1         4  
6 1     1   51 use Future::AsyncAwait;
  1         1  
  1         5  
7 1     1   41 use Scalar::Util 'blessed';
  1         1  
  1         1099  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::XSendfile - Delegate file serving to reverse proxy
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             # For Nginx (X-Accel-Redirect)
18             my $app = builder {
19             enable 'XSendfile',
20             type => 'X-Accel-Redirect',
21             mapping => '/protected/files/'; # URL prefix for internal location
22             $my_app;
23             };
24              
25             # For Apache (mod_xsendfile)
26             my $app = builder {
27             enable 'XSendfile',
28             type => 'X-Sendfile';
29             $my_app;
30             };
31              
32             # For Lighttpd
33             my $app = builder {
34             enable 'XSendfile',
35             type => 'X-Lighttpd-Send-File';
36             $my_app;
37             };
38              
39             =head1 DESCRIPTION
40              
41             PAGI::Middleware::XSendfile intercepts file responses and replaces them with
42             a special header that tells the reverse proxy (Nginx, Apache, Lighttpd) to
43             serve the file directly. This is the recommended approach for serving large
44             files in production, as it:
45              
46             =over 4
47              
48             =item * Frees up your application worker immediately
49              
50             =item * Uses the reverse proxy's optimized sendfile implementation
51              
52             =item * Supports all the proxy's features (caching, range requests, etc.)
53              
54             =back
55              
56             =head2 How It Works
57              
58             When your application sends a file response:
59              
60             await $send->({
61             type => 'http.response.body',
62             file => '/var/www/files/large.bin',
63             });
64              
65             This middleware intercepts it and instead sends:
66              
67             # Headers include: X-Accel-Redirect: /protected/files/large.bin
68             # Body is empty - proxy serves the file
69              
70             =head1 REVERSE PROXY CONFIGURATION
71              
72             =head2 Nginx
73              
74             Configure an internal location that maps to your files:
75              
76             location /protected/files/ {
77             internal;
78             alias /var/www/files/;
79             }
80              
81             Then use:
82              
83             enable 'XSendfile',
84             type => 'X-Accel-Redirect',
85             mapping => { '/var/www/files/' => '/protected/files/' };
86              
87             =head2 Apache
88              
89             Enable mod_xsendfile and allow sending from your directory:
90              
91             XSendFile On
92             XSendFilePath /var/www/files
93              
94             Then use:
95              
96             enable 'XSendfile', type => 'X-Sendfile';
97              
98             =head2 Lighttpd
99              
100             Enable mod_fastcgi with C option:
101              
102             fastcgi.server = (
103             "/" => ((
104             "socket" => "/tmp/app.sock",
105             "x-sendfile" => "enable"
106             ))
107             )
108              
109             Then use:
110              
111             enable 'XSendfile', type => 'X-Lighttpd-Send-File';
112              
113             =head1 CONFIGURATION
114              
115             =over 4
116              
117             =item * type (required)
118              
119             The header type to use. One of:
120              
121             X-Accel-Redirect - Nginx
122             X-Sendfile - Apache mod_xsendfile
123             X-Lighttpd-Send-File - Lighttpd
124              
125             =item * mapping (for X-Accel-Redirect)
126              
127             Path mapping from filesystem paths to Nginx internal URLs. Can be:
128              
129             A string prefix (simple case):
130              
131             mapping => '/protected/'
132             # /var/www/files/foo.txt => /protected/var/www/files/foo.txt
133              
134             A hashref for path translation:
135              
136             mapping => { '/var/www/files/' => '/protected/' }
137             # /var/www/files/foo.txt => /protected/foo.txt
138              
139             =item * variation (optional)
140              
141             Additional string appended to Vary header to prevent caching issues.
142              
143             =back
144              
145             =head1 RANGE REQUESTS / PARTIAL CONTENT
146              
147             B
148              
149             When using XSendfile with a reverse proxy, you should disable Range request
150             handling in your file-serving app (L or L)
151             and let the proxy handle Range requests natively:
152              
153             use PAGI::Middleware::Builder;
154             use PAGI::App::File;
155              
156             my $app = builder {
157             enable 'XSendfile',
158             type => 'X-Accel-Redirect',
159             mapping => { '/var/www/files/' => '/protected/' };
160              
161             PAGI::App::File->new(
162             root => '/var/www/files',
163             handle_ranges => 0, # Let nginx handle Range requests
164             )->to_app;
165             };
166              
167             With C 0>:
168              
169             =over 4
170              
171             =item * Your app always sends full file paths via X-Sendfile
172              
173             =item * The proxy receives Range headers directly from clients
174              
175             =item * The proxy handles Range requests using its optimized sendfile
176              
177             =back
178              
179             This is more efficient than handling ranges in Perl.
180              
181             =head2 Why Partial Responses Bypass XSendfile
182              
183             If your app does process Range requests (the default behavior), it sends
184             file responses with C and C. This middleware will pass
185             such responses through unchanged because reverse proxies don't support
186             byte range parameters in X-Sendfile headers:
187              
188             # This will use X-Sendfile (full file)
189             await $send->({
190             type => 'http.response.body',
191             file => '/path/to/file.bin',
192             });
193              
194             # This will bypass X-Sendfile (partial content)
195             await $send->({
196             type => 'http.response.body',
197             file => '/path/to/file.bin',
198             offset => 1000,
199             length => 500,
200             });
201              
202             The recommended approach is to set C 0> so your app
203             never produces partial responses, and let the proxy handle Range requests.
204              
205             =head1 FILEHANDLE SUPPORT
206              
207             This middleware supports two types of file responses:
208              
209             =head2 File Path (Recommended)
210              
211             await $send->({
212             type => 'http.response.body',
213             file => '/path/to/file.bin',
214             });
215              
216             This always works - the path is used directly.
217              
218             =head2 Filehandle with path() Method
219              
220             use IO::File::WithPath; # or similar
221             my $fh = IO::File::WithPath->new('/path/to/file.bin', 'r');
222              
223             await $send->({
224             type => 'http.response.body',
225             fh => $fh,
226             });
227              
228             For filehandle responses, the middleware will only intercept if the
229             filehandle object has a C method that returns the filesystem path.
230             This is compatible with:
231              
232             =over 4
233              
234             =item * L
235              
236             =item * L
237              
238             =item * Any blessed filehandle with a C method
239              
240             =back
241              
242             B
243             (not via X-Sendfile). If you need X-Sendfile support for filehandles,
244             add a C method to your IO object:
245              
246             # Simple approach: bless and add path method
247             sub make_sendfile_fh {
248             my ($path) = @_;
249             open my $fh, '<', $path or die $!;
250             bless $fh, 'My::FH::WithPath';
251             return $fh;
252             }
253              
254             package My::FH::WithPath;
255             sub path { ${*{$_[0]}}{path} } # or store path however you prefer
256              
257             =cut
258              
259             my %VALID_TYPES = (
260             'X-Accel-Redirect' => 1,
261             'X-Sendfile' => 1,
262             'X-Lighttpd-Send-File' => 1,
263             );
264              
265             sub _init {
266 16     16   21 my ($self, $config) = @_;
267              
268             my $type = $config->{type}
269 16 100       85 or die "XSendfile middleware requires 'type' parameter";
270              
271             die "Invalid XSendfile type '$type'. Must be one of: "
272             . join(', ', sort keys %VALID_TYPES)
273 15 100       55 unless $VALID_TYPES{$type};
274              
275 14         24 $self->{type} = $type;
276 14         21 $self->{mapping} = $config->{mapping};
277 14         29 $self->{variation} = $config->{variation};
278             }
279              
280             sub wrap {
281 14     14 1 125 my ($self, $app) = @_;
282              
283 14     14   206 return async sub {
284 14         21 my ($scope, $receive, $send) = @_;
285              
286             # Only handle HTTP requests
287 14 100       33 if ($scope->{type} ne 'http') {
288 1         3 await $app->($scope, $receive, $send);
289 1         66 return;
290             }
291              
292 13         13 my $pending_start;
293              
294 27         639 my $wrapped_send = async sub {
295 27         29 my ($event) = @_;
296 27         29 my $type = $event->{type};
297              
298 27 100       44 if ($type eq 'http.response.start') {
299             # Buffer the start event - we might need to modify headers
300 13         27 $pending_start = $event;
301 13         89 return;
302             }
303              
304 14 50       19 if ($type eq 'http.response.body') {
305 14         26 my $path = $self->_extract_path($event);
306              
307             # Skip XSendfile for partial content (offset/length) - proxies don't support it
308 14   100     71 my $is_partial = defined $event->{offset} || defined $event->{length};
309              
310 14 100 100     36 if (defined $path && !$is_partial) {
311             # We have a full file path - use X-Sendfile
312 8         15 my $mapped_path = $self->_map_path($path);
313 8   50     8 my @headers = @{$pending_start->{headers} // []};
  8         20  
314              
315             # Add the X-Sendfile header
316 8         13 push @headers, [$self->{type}, $mapped_path];
317              
318             # Add Vary header if configured
319 8 100       14 if ($self->{variation}) {
320 1         3 push @headers, ['Vary', $self->{variation}];
321             }
322              
323             # Send response with empty body
324             await $send->({
325             type => 'http.response.start',
326             status => $pending_start->{status},
327             headers => \@headers,
328 8         53 });
329 8         292 await $send->({
330             type => 'http.response.body',
331             body => '',
332             });
333              
334 8         192 $pending_start = undef;
335 8         20 return;
336             }
337             }
338              
339             # Not a file response, or no path available - pass through
340 6 100       11 if ($pending_start) {
341 5         9 await $send->($pending_start);
342 5         135 $pending_start = undef;
343             }
344 6         8 await $send->($event);
345 13         57 };
346              
347 13         22 await $app->($scope, $receive, $wrapped_send);
348              
349             # Flush any pending start that wasn't followed by a body
350 13 50       875 if ($pending_start) {
351 0         0 await $send->($pending_start);
352             }
353 14         55 };
354             }
355              
356             sub _extract_path {
357 14     14   17 my ($self, $event) = @_;
358              
359             # Direct file path - always works
360 14 100       33 return $event->{file} if defined $event->{file};
361              
362             # Filehandle - only if it has a path() method
363 5 100       15 if (my $fh = $event->{fh}) {
364 2 100 66     19 if (blessed($fh) && $fh->can('path')) {
365 1         3 my $path = $fh->path;
366 1 50 33     7 return $path if defined $path && length $path;
367             }
368             }
369              
370 4         5 return undef;
371             }
372              
373             sub _map_path {
374 8     8   9 my ($self, $path) = @_;
375              
376 8         10 my $mapping = $self->{mapping};
377              
378             # No mapping - return path as-is (for X-Sendfile/Lighttpd)
379 8 100       16 return $path unless defined $mapping;
380              
381             # Simple string prefix
382 2 100       4 if (!ref $mapping) {
383 1         4 return $mapping . $path;
384             }
385              
386             # Hash mapping - find matching prefix and replace
387 1 50       4 if (ref $mapping eq 'HASH') {
388 1         2 for my $from (keys %$mapping) {
389 1 50       4 if (substr($path, 0, length($from)) eq $from) {
390 1         1 my $to = $mapping->{$from};
391 1         4 return $to . substr($path, length($from));
392             }
393             }
394             }
395              
396             # No mapping matched - return as-is
397 0           return $path;
398             }
399              
400             1;
401              
402             __END__