PAGI

 view release on metacpan or  search on metacpan

lib/PAGI/App/File.pm  view on Meta::CPAN

package PAGI::App::File;

use strict;
use warnings;
use Future::AsyncAwait;
use Digest::MD5 qw(md5_hex);
use File::Spec;
use Cwd ();  # For realpath

=head1 NAME

PAGI::App::File - Serve static files

=head1 SYNOPSIS

    use PAGI::App::File;

    my $app = PAGI::App::File->new(
        root => '/var/www/static',
    )->to_app;

=head1 DESCRIPTION

PAGI::App::File serves static files from a configured root directory.

=head2 Features

=over 4

=item * Efficient streaming (no memory bloat for large files)

=item * ETag caching with If-None-Match support (304 Not Modified)

=item * Range requests (HTTP 206 Partial Content)

=item * Automatic MIME type detection for common file types

=item * Index file resolution (index.html, index.htm)

=back

=head2 Security

This module implements multiple layers of path traversal protection:

=over 4

=item * Null byte injection blocking

=item * Double-dot and triple-dot component blocking

=item * Backslash normalization (Windows path separator)

=item * Hidden file blocking (dotfiles like .htaccess, .env)

=item * Symlink escape detection via realpath verification

=back

=cut

our %MIME_TYPES = (
    html => 'text/html',
    htm  => 'text/html',
    css  => 'text/css',
    js   => 'application/javascript',
    json => 'application/json',
    xml  => 'application/xml',
    txt  => 'text/plain',
    pl   => 'text/plain',
    md   => 'text/plain',
    png  => 'image/png',
    jpg  => 'image/jpeg',
    jpeg => 'image/jpeg',
    gif  => 'image/gif',
    svg  => 'image/svg+xml',
    ico  => 'image/x-icon',
    webp => 'image/webp',
    woff => 'font/woff',
    woff2=> 'font/woff2',
    ttf  => 'font/ttf',
    pdf  => 'application/pdf',
    zip  => 'application/zip',
    mp3  => 'audio/mpeg',
    mp4  => 'video/mp4',
    webm => 'video/webm',
);

sub new {
    my ($class, %args) = @_;

lib/PAGI/App/File.pm  view on Meta::CPAN


        unless (-f $file_path && -r $file_path) {
            await $self->_send_error($send, 404, 'Not Found');
            return;
        }

        # Security: Verify resolved path stays within root (prevents symlink escape)
        my $real_path = Cwd::realpath($file_path);
        unless ($real_path && index($real_path, $root) == 0) {
            await $self->_send_error($send, 403, 'Forbidden');
            return;
        }

        my @stat = stat($file_path);
        my $size = $stat[7];
        my $mtime = $stat[9];
        my $etag = '"' . md5_hex("$mtime-$size") . '"';

        # Check If-None-Match
        my $if_none_match = $self->_get_header($scope, 'if-none-match');
        if ($if_none_match && $if_none_match eq $etag) {
            await $send->({
                type => 'http.response.start',
                status => 304,
                headers => [['etag', $etag]],
            });
            await $send->({ type => 'http.response.body', body => '', more => 0 });
            return;
        }

        # Determine MIME type
        my ($ext) = $file_path =~ /\.([^.]+)$/;
        my $content_type = $MIME_TYPES{lc($ext // '')} // $self->{default_type};

        # Check for Range request (only if handle_ranges is enabled)
        my $range = $self->{handle_ranges} ? $self->_get_header($scope, 'range') : undef;
        if ($range && $range =~ /bytes=(\d*)-(\d*)/) {
            my ($start, $end) = ($1, $2);
            $start = 0 if $start eq '';
            $end = $size - 1 if $end eq '' || $end >= $size;

            if ($start > $end || $start >= $size) {
                await $self->_send_error($send, 416, 'Range Not Satisfiable');
                return;
            }

            my $length = $end - $start + 1;

            await $send->({
                type => 'http.response.start',
                status => 206,
                headers => [
                    ['content-type', $content_type],
                    ['content-length', $length],
                    ['content-range', "bytes $start-$end/$size"],
                    ['accept-ranges', 'bytes'],
                    ['etag', $etag],
                ],
            });

            # Use file response with offset/length for efficient streaming
            if ($method eq 'HEAD') {
                await $send->({ type => 'http.response.body', body => '', more => 0 });
            }
            else {
                await $send->({
                    type   => 'http.response.body',
                    file   => $file_path,
                    offset => $start,
                    length => $length,
                });
            }
            return;
        }

        # Full file response
        await $send->({
            type => 'http.response.start',
            status => 200,
            headers => [
                ['content-type', $content_type],
                ['content-length', $size],
                ['accept-ranges', 'bytes'],
                ['etag', $etag],
            ],
        });

        # Use file response for efficient streaming (sendfile or worker pool)
        if ($method eq 'HEAD') {
            await $send->({ type => 'http.response.body', body => '', more => 0 });
        }
        else {
            await $send->({
                type => 'http.response.body',
                file => $file_path,
            });
        }
    };
}

sub _get_header {
    my ($self, $scope, $name) = @_;

    $name = lc($name);
    for my $h (@{$scope->{headers} // []}) {
        return $h->[1] if lc($h->[0]) eq $name;
    }
    return;
}

async sub _send_error {
    my ($self, $send, $status, $message) = @_;

    await $send->({
        type => 'http.response.start',
        status => $status,
        headers => [['content-type', 'text/plain'], ['content-length', length($message)]],
    });
    await $send->({ type => 'http.response.body', body => $message, more => 0 });
}

1;

__END__

=head1 CONFIGURATION

=over 4

=item * root - Root directory for files

=item * default_type - Default MIME type (default: application/octet-stream)

=item * index - Index file names (default: [index.html, index.htm])

=item * handle_ranges - Process Range headers (default: 1)

When enabled (default), the app processes Range request headers and returns
206 Partial Content responses. Set to 0 to ignore Range headers and always
return the full file.

B<When to disable Range handling:>

When using L<PAGI::Middleware::XSendfile> with a reverse proxy (Nginx, Apache),
you should disable range handling. The proxy will handle Range requests more
efficiently using its native sendfile implementation:

    my $app = PAGI::App::File->new(



( run in 0.966 second using v1.01-cache-2.11-cpan-140bd7fdf52 )