PAGI

 view release on metacpan or  search on metacpan

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

    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) = @_;

    my $root = $args{root} // '.';
    # Resolve root to absolute path for security comparisons
    my $abs_root = Cwd::realpath($root) // $root;

    my $self = bless {
        root          => $abs_root,
        default_type  => $args{default_type} // 'application/octet-stream',
        index         => $args{index} // ['index.html', 'index.htm'],
        handle_ranges => $args{handle_ranges} // 1,
    }, $class;
    return $self;
}

sub to_app {
    my ($self) = @_;

    my $root = $self->{root};

    return async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported scope type: $scope->{type}" if $scope->{type} ne 'http';

        my $method = uc($scope->{method} // '');
        unless ($method eq 'GET' || $method eq 'HEAD') {
            await $self->_send_error($send, 405, 'Method Not Allowed');
            return;
        }

        my $path = $scope->{path} // '/';

        # Security: Block null byte injection
        if ($path =~ /\0/) {
            await $self->_send_error($send, 400, 'Bad Request');
            return;
        }

        # Security: Normalize backslashes to forward slashes
        $path =~ s{\\}{/}g;

        # Security: Split path and validate each component
        # Use -1 limit to preserve trailing empty strings
        my @components = split m{/}, $path, -1;
        for my $component (@components) {
            # Block components with 2+ dots (.. , ..., ....)
            if ($component =~ /^\.{2,}$/) {
                await $self->_send_error($send, 403, 'Forbidden');
                return;
            }
            # Block hidden files (dotfiles) - components starting with .
            if ($component =~ /^\./ && $component ne '') {
                await $self->_send_error($send, 403, 'Forbidden');
                return;
            }
        }

        # Build file path using File::Spec for portability
        $path =~ s{^/+}{};
        my $file_path = File::Spec->catfile($root, $path);

        # Check for index files if directory
        if (-d $file_path) {
            for my $index (@{$self->{index}}) {
                my $index_path = File::Spec->catfile($file_path, $index);
                if (-f $index_path) {
                    $file_path = $index_path;
                    last;
                }
            }
        }

        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};



( run in 1.274 second using v1.01-cache-2.11-cpan-71847e10f99 )